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:
Masahiko AMANO 2026-04-05 03:06:32 +03:00
parent fde8672bb1
commit e21d0ef67b
4 changed files with 200 additions and 2 deletions

View File

@ -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 };

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB