4201917e17
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m17s
Restrict Docker/Files writes to admins, move terminal websocket auth to cookie-first with temporary query fallback, and migrate refresh-token handling to httpOnly cookies for safer session persistence. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
231 lines
10 KiB
Svelte
231 lines
10 KiB
Svelte
<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);
|
|
let showPasswordForm = $state(!window.isSecureContext);
|
|
|
|
function base64urlToBuffer(b64) {
|
|
const s = b64.replace(/-/g, '+').replace(/_/g, '/');
|
|
const raw = atob(s);
|
|
return Uint8Array.from(raw, c => c.charCodeAt(0)).buffer;
|
|
}
|
|
|
|
function bufferToBase64url(buf) {
|
|
const bytes = new Uint8Array(buf);
|
|
let s = '';
|
|
bytes.forEach(b => s += String.fromCharCode(b));
|
|
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
}
|
|
|
|
async function loginWithPasskey() {
|
|
loading = true;
|
|
error = "";
|
|
try {
|
|
const r = await fetch("/api/auth/passkey/login/options", { method: "POST" });
|
|
if (!r.ok) throw new Error("No passkeys registered");
|
|
const opts = await r.json();
|
|
opts.challenge = base64urlToBuffer(opts.challenge);
|
|
if (opts.allowCredentials) {
|
|
opts.allowCredentials = opts.allowCredentials.map(c => ({
|
|
...c, id: base64urlToBuffer(c.id)
|
|
}));
|
|
}
|
|
const cred = await navigator.credentials.get({ publicKey: opts });
|
|
const body = {
|
|
id: cred.id,
|
|
rawId: bufferToBase64url(cred.rawId),
|
|
type: cred.type,
|
|
response: {
|
|
authenticatorData: bufferToBase64url(cred.response.authenticatorData),
|
|
clientDataJSON: bufferToBase64url(cred.response.clientDataJSON),
|
|
signature: bufferToBase64url(cred.response.signature),
|
|
userHandle: cred.response.userHandle ? bufferToBase64url(cred.response.userHandle) : null,
|
|
},
|
|
};
|
|
const res = await fetch("/api/auth/passkey/login/verify", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); }
|
|
const data = await res.json();
|
|
setToken(data.access_token);
|
|
window.location.reload();
|
|
} catch (e) {
|
|
if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; }
|
|
else { error = e.message || "Passkey login failed"; }
|
|
showPasswordForm = true;
|
|
} finally {
|
|
loading = 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>
|
|
|
|
<div class="mt-8 space-y-6">
|
|
{#if !showPasswordForm && !require2FA}
|
|
<button
|
|
type="button"
|
|
disabled={loading}
|
|
onclick={loginWithPasskey}
|
|
class="flex w-full justify-center rounded-lg bg-surface-800 dark:bg-surface-700 px-3 py-2.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-surface-700 dark:hover:bg-surface-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}
|
|
Sign in with Passkey
|
|
</button>
|
|
|
|
<div class="relative">
|
|
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-surface-200 dark:border-surface-700"></div></div>
|
|
<div class="relative flex justify-center text-xs"><span class="bg-white dark:bg-surface-900 px-2 text-surface-400">or</span></div>
|
|
</div>
|
|
|
|
<button type="button" onclick={() => showPasswordForm = true} class="flex w-full justify-center text-sm text-primary-500 hover:text-primary-400">
|
|
Use password instead
|
|
</button>
|
|
{/if}
|
|
|
|
{#if showPasswordForm || require2FA}
|
|
<form class="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>
|
|
|
|
<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>
|
|
</form>
|
|
{/if}
|
|
|
|
{#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>
|
|
</div>
|
|
</div>
|