feat(frontend): implement auth store and login page
Rewrites auth store with typed AuthUser shape (id, name, isAdmin) and localStorage persistence. Adds login page with tanabata decorative images, centered form, purple primary button matching the reference design. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fde8672bb1
commit
e21d0ef67b
@ -1,10 +1,15 @@
|
|||||||
import { writable, derived } from 'svelte/store';
|
import { writable, derived } from 'svelte/store';
|
||||||
import type { User } from '$lib/api/types';
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
refreshToken: string | null;
|
refreshToken: string | null;
|
||||||
user: User | null;
|
user: AuthUser | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initial: AuthState = { accessToken: null, refreshToken: null, user: null };
|
const initial: AuthState = { accessToken: null, refreshToken: null, user: null };
|
||||||
|
|||||||
193
frontend/src/routes/login/+page.svelte
Normal file
193
frontend/src/routes/login/+page.svelte
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { login } from '$lib/api/auth';
|
||||||
|
import { api } from '$lib/api/client';
|
||||||
|
import { ApiError } from '$lib/api/client';
|
||||||
|
import { authStore } from '$lib/stores/auth';
|
||||||
|
import type { User } from '$lib/api/types';
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = '';
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
await login(name, password);
|
||||||
|
const me = await api.get<User>('/users/me');
|
||||||
|
authStore.update((s) => ({
|
||||||
|
...s,
|
||||||
|
user: {
|
||||||
|
id: me.id!,
|
||||||
|
name: me.name!,
|
||||||
|
isAdmin: me.is_admin ?? false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
await goto('/files');
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
error = 'Invalid username or password.';
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
error = err.message;
|
||||||
|
} else {
|
||||||
|
error = 'An unexpected error occurred.';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Welcome to Tanabata File Manager!</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="login-root">
|
||||||
|
<img src="/images/tanabata-left.png" alt="" class="decoration left" aria-hidden="true" />
|
||||||
|
<img src="/images/tanabata-right.png" alt="" class="decoration right" aria-hidden="true" />
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} novalidate>
|
||||||
|
<h1>Welcome to<br />Tanabata File Manager!</h1>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
placeholder="Username..."
|
||||||
|
autocomplete="username"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
bind:value={name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="Password..."
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
bind:value={password}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<button type="submit" disabled={loading || !name || !password}>
|
||||||
|
{loading ? 'Logging in…' : 'Log in'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decoration {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 20vw;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decoration.left { left: 0; }
|
||||||
|
.decoration.right { right: 0; }
|
||||||
|
|
||||||
|
form {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(380px, calc(100vw - 48px));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 0 0 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 20%, transparent);
|
||||||
|
border: 1px solid var(--color-danger);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
height: 52px;
|
||||||
|
outline: none;
|
||||||
|
padding: 0 16px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder { color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
border: 1px solid #454261;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
height: 50px;
|
||||||
|
margin-top: 20px;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
frontend/static/images/tanabata-left.png
vendored
Normal file
BIN
frontend/static/images/tanabata-left.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
BIN
frontend/static/images/tanabata-right.png
vendored
Normal file
BIN
frontend/static/images/tanabata-right.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
Loading…
x
Reference in New Issue
Block a user