Phase 7: Auth & Security upgrade (JWT, 2FA, Login UI)

This commit is contained in:
Gan, Jimmy
2026-02-19 03:47:48 +08:00
parent f3db7d3654
commit 651bc0580e
12 changed files with 495 additions and 38 deletions
+73 -19
View File
@@ -5,31 +5,85 @@
import Gitea from "./routes/Gitea.svelte";
import Files from "./routes/Files.svelte";
import Terminal from "./routes/Terminal.svelte";
import Login from "./routes/Login.svelte";
import { onMount } from "svelte";
import { getToken, checkAuth, setToken } from "./lib/api.js";
let page = $state("dashboard");
let dark = $state(false);
let authorized = $state(false);
let loading = $state(true);
onMount(async () => {
// Theme init
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
dark = true;
document.documentElement.classList.add('dark');
} else {
dark = false;
document.documentElement.classList.remove('dark');
}
// Auth check
const token = getToken();
if (!token) {
loading = false;
authorized = false;
return;
}
try {
await checkAuth();
authorized = true;
} catch (e) {
console.error("Auth check failed:", e);
setToken("");
authorized = false;
} finally {
loading = false;
}
});
function toggleTheme() {
dark = !dark;
document.documentElement.classList.toggle("dark", dark);
if (dark) {
document.documentElement.classList.add("dark");
localStorage.theme = 'dark';
} else {
document.documentElement.classList.remove("dark");
localStorage.theme = 'light';
}
}
function logout() {
setToken("");
window.location.reload();
}
</script>
<div class="flex h-screen overflow-hidden bg-surface-50 {dark ? 'dark bg-surface-900' : ''}">
<Sidebar bind:page {dark} {toggleTheme} />
<main class="flex-1 overflow-auto">
<div class="max-w-[1400px] mx-auto p-6 lg:p-8">
{#if page === "dashboard"}
<Dashboard />
{:else if page === "docker"}
<Docker />
{:else if page === "gitea"}
<Gitea />
{:else if page === "files"}
<Files />
{:else if page === "terminal"}
<Terminal />
{/if}
</div>
</main>
</div>
{#if loading}
<div class="flex h-screen items-center justify-center bg-surface-50 dark:bg-surface-950">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
{:else if !authorized}
<Login />
{:else}
<div class="flex h-screen overflow-hidden bg-surface-50 {dark ? 'dark bg-surface-900' : ''}">
<Sidebar bind:page {dark} {toggleTheme} {logout} />
<main class="flex-1 overflow-auto">
<div class="max-w-[1400px] mx-auto p-6 lg:p-8">
{#if page === "dashboard"}
<Dashboard />
{:else if page === "docker"}
<Docker />
{:else if page === "gitea"}
<Gitea />
{:else if page === "files"}
<Files />
{:else if page === "terminal"}
<Terminal />
{/if}
</div>
</main>
</div>
{/if}
@@ -92,7 +92,7 @@
<div class="px-4 pb-4 pt-2 border-t border-surface-200 {dark ? 'border-surface-700' : ''}">
<button
onclick={toggleTheme}
class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 {dark ? 'hover:bg-surface-700 text-surface-400' : ''}"
class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 mb-1 {dark ? 'hover:bg-surface-700 text-surface-400' : ''}"
>
{#if dark}
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
@@ -102,5 +102,12 @@
Dark Mode
{/if}
</button>
<button
onclick={logout}
class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 hover:text-red-600 {dark ? 'hover:bg-surface-700 text-surface-400 hover:text-red-400' : ''}"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
Sign out
</button>
</div>
</aside>
+58 -6
View File
@@ -1,13 +1,49 @@
const BASE = "/api";
let token = localStorage.getItem("token") || "";
export function setToken(t) {
token = t;
if (t) localStorage.setItem("token", t);
else localStorage.removeItem("token");
}
export function getToken() {
return token;
}
async function request(path, opts = {}) {
const headers = opts.headers || {};
if (token) headers["Authorization"] = `Bearer ${token}`;
if (opts.json) {
headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(opts.json);
delete opts.json;
}
try {
const r = await fetch(BASE + path, opts);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const r = await fetch(BASE + path, { ...opts, headers });
if (r.status === 401) {
// Session expired or invalid
setToken("");
// Only reload if not already on login page (simple check)
if (!location.pathname.includes("login") && !token) {
// Let the app handle redirect, but clearer is to throw
}
throw new Error("Unauthorized");
}
if (!r.ok) {
const error = new Error(`HTTP ${r.status}`);
error.status = r.status;
try { error.body = await r.json(); } catch { }
throw error;
}
return r.json();
} catch (e) {
console.error(`API ${opts.method || "GET"} ${path}:`, e);
return null;
throw e;
}
}
@@ -15,8 +51,8 @@ export function get(path) {
return request(path);
}
export function post(path) {
return request(path, { method: "POST" });
export function post(path, data) {
return request(path, { method: "POST", json: data });
}
export function del(path) {
@@ -24,10 +60,26 @@ export function del(path) {
}
export async function upload(path, file) {
const headers = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const form = new FormData();
form.append("file", file);
return request(`/files/upload?path=${encodeURIComponent(path)}`, {
const r = await fetch(`${BASE}/files/upload?path=${encodeURIComponent(path)}`, {
method: "POST",
headers,
body: form,
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
export function login(creds) {
return request("/auth/login", { method: "POST", json: creds });
}
export function checkAuth() {
return request("/auth/me");
}
+145
View File
@@ -0,0 +1,145 @@
<script>
import { login, setToken } from "../lib/api.js";
import { fade, fly } from "svelte/transition";
let username = $state("");
let password = $state("");
let totp_code = $state("");
let error = $state("");
let loading = $state(false);
let require2FA = $state(false);
async function handleSubmit(e) {
e.preventDefault();
loading = true;
error = "";
try {
const payload = { username, password };
if (require2FA) payload.totp_code = totp_code;
const res = await login(payload); // api.js helper sends JSON
setToken(res.access_token);
// Determine if we need to reload or just update state.
// Reload is safest to clear any stale state.
window.location.reload();
} catch (e) {
if (e.status === 403 && e.body?.detail === "2FA code required") {
require2FA = true;
// Clear error if any
error = "";
} else {
error = e.body?.detail || "Login failed. Please check your credentials.";
// Reset 2FA state on general failure (maybe password was wrong this time)
if (!require2FA) {
password = "";
}
}
} finally {
loading = false;
}
}
</script>
<div class="flex min-h-screen items-center justify-center bg-surface-50 dark:bg-surface-950 px-4">
<div class="w-full max-w-sm space-y-8 bg-white dark:bg-surface-900 p-8 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-800" in:fly={{ y: 20, duration: 400 }}>
<div class="text-center">
<div class="mx-auto h-12 w-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center shadow-lg mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h2 class="text-2xl font-bold tracking-tight text-surface-900 dark:text-white">Sign in to NAS</h2>
<p class="mt-2 text-sm text-surface-600 dark:text-surface-400">
Enter your credentials to access the dashboard
</p>
</div>
<form class="mt-8 space-y-6" onsubmit={handleSubmit}>
<div class="space-y-4">
{#if !require2FA}
<div>
<label for="username" class="sr-only">Username</label>
<input
id="username"
name="username"
type="text"
required
bind:value={username}
class="relative block w-full rounded-lg border-0 py-2.5 px-3 text-surface-900 dark:text-white ring-1 ring-inset ring-surface-300 dark:ring-surface-700 placeholder:text-surface-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-primary-600 dark:bg-surface-800 sm:text-sm sm:leading-6"
placeholder="Username"
/>
</div>
<div>
<label for="password" class="sr-only">Password</label>
<input
id="password"
name="password"
type="password"
required
bind:value={password}
class="relative block w-full rounded-lg border-0 py-2.5 px-3 text-surface-900 dark:text-white ring-1 ring-inset ring-surface-300 dark:ring-surface-700 placeholder:text-surface-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-primary-600 dark:bg-surface-800 sm:text-sm sm:leading-6"
placeholder="Password"
/>
</div>
{:else}
<div in:fade>
<label for="totp" class="block text-sm font-medium leading-6 text-surface-900 dark:text-white text-center mb-2">Authenticator Code</label>
<input
id="totp"
name="totp"
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="one-time-code"
required
autofocus
bind:value={totp_code}
class="block w-full text-center tracking-[0.5em] text-2xl font-mono rounded-lg border-0 py-3 text-surface-900 dark:text-white ring-1 ring-inset ring-surface-300 dark:ring-surface-700 placeholder:text-surface-400 focus:ring-2 focus:ring-inset focus:ring-primary-600 dark:bg-surface-800"
placeholder="000000"
maxlength="6"
/>
<p class="mt-4 text-center text-sm text-surface-500">
<button type="button" class="text-primary-500 hover:text-primary-400" onclick={() => require2FA = false}>
Back to credentials
</button>
</p>
</div>
{/if}
</div>
{#if error}
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-3" in:fade>
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">{error}</h3>
</div>
</div>
</div>
{/if}
<div>
<button
type="submit"
disabled={loading}
class="flex w-full justify-center rounded-lg bg-primary-600 px-3 py-2.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:opacity-70 transition-all active:scale-[0.98]"
>
{#if loading}
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{/if}
{require2FA ? "Verify Code" : "Sign in"}
</button>
</div>
</form>
</div>
</div>