diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte new file mode 100644 index 00000000..ba6ecd61 --- /dev/null +++ b/src/routes/about/+page.svelte @@ -0,0 +1,9 @@ + + +{@html data.seo} + +

{m.hello_world({ name: 'World' })}

+
{JSON.stringify(data, null, 2)}
diff --git a/src/routes/about/+page.ts b/src/routes/about/+page.ts new file mode 100644 index 00000000..61e3d993 --- /dev/null +++ b/src/routes/about/+page.ts @@ -0,0 +1,6 @@ +import { getJson } from '$lib/api' + +export const load = async ({ fetch }) => { + const status = await getJson('/api/v1/version', fetch) + return { status } +} diff --git a/src/routes/auth/login/+server.ts b/src/routes/auth/login/+server.ts new file mode 100644 index 00000000..17dc9120 --- /dev/null +++ b/src/routes/auth/login/+server.ts @@ -0,0 +1,47 @@ +import type { RequestHandler } from '@sveltejs/kit' +import { json } from '@sveltejs/kit' +import { z } from 'zod' +import { passwordGrantLogin } from '$lib/auth/oauth' +import { users } from '$lib/api/resources/users' +import { buildCookies } from '$lib/auth/map' +import { setAccountCookie, setUserCookie, setRefreshCookie } from '$lib/auth/cookies' + +const LoginSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + grant_type: z.literal('password') +}) + +export const POST: RequestHandler = async ({ request, cookies, url, fetch }) => { + const raw = await request.json().catch(() => ({})) + const parsed = LoginSchema.safeParse(raw) + if (!parsed.success) { + const details = parsed.error.flatten((i) => i.message) + return json({ error: 'Validation error', details }, { status: 400 }) + } + + try { + const oauth = await passwordGrantLogin(fetch, parsed.data) + + const info = await users.info(fetch, oauth.user.username, { + headers: { + Authorization: `Bearer ${oauth.access_token}` + } + }) + + const { account, user, accessTokenExpiresAt, refresh } = buildCookies(oauth, info) + + const secure = url.protocol === 'https:' + setAccountCookie(cookies, account, { secure, expires: accessTokenExpiresAt }) + setUserCookie(cookies, user, { secure, expires: accessTokenExpiresAt }) + setRefreshCookie(cookies, refresh, { secure, expires: accessTokenExpiresAt }) + + return json({ success: true, user: { username: info.username, avatar: info.avatar } }) + } catch (e: any) { + if (String(e?.message) === 'unauthorized') { + return json({ error: 'Invalid email or password' }, { status: 401 }) + } + + return json({ error: 'Failed to login' }, { status: 502 }) + } +} diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts new file mode 100644 index 00000000..6aca8f1c --- /dev/null +++ b/src/routes/auth/logout/+server.ts @@ -0,0 +1,8 @@ +import type { RequestHandler } from '@sveltejs/kit' +import { json } from '@sveltejs/kit' + +export const POST: RequestHandler = async ({ cookies }) => { + cookies.delete('account', { path: '/' }) + cookies.delete('user', { path: '/' }) + return json({ success: true }) +} diff --git a/src/routes/auth/refresh/+server.ts b/src/routes/auth/refresh/+server.ts new file mode 100644 index 00000000..25ad6433 --- /dev/null +++ b/src/routes/auth/refresh/+server.ts @@ -0,0 +1,73 @@ +import type { RequestHandler } from '@sveltejs/kit' +import { json } from '@sveltejs/kit' +import { PUBLIC_SIERO_OAUTH_URL } from '$env/static/public' +import { + getRefreshFromCookies, + setAccountCookie, + setRefreshCookie, + clearAuthCookies +} from '$lib/auth/cookies' + +type OAuthRefreshResponse = { + access_token: string + token_type: 'Bearer' + expires_in: number + refresh_token: string + created_at: number + user: { + id: string + username: string + role: number + } +} + +export const POST: RequestHandler = async ({ cookies, fetch, url }) => { + const refresh = getRefreshFromCookies(cookies) + if (!refresh) { + return json({ error: 'no_refresh_token' }, { status: 401 }) + } + + const res = await fetch(`${PUBLIC_SIERO_OAUTH_URL}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + refresh_token: refresh, + grant_type: 'refresh_token' + }) + }) + + if (res.status === 401) { + clearAuthCookies(cookies) + return json({ error: 'refresh_unauthorized' }, { status: 401 }) + } + + if (!res.ok) { + return json({ error: 'refresh_failed' }, { status: 502 }) + } + + const data = (await res.json()) as OAuthRefreshResponse + const secure = url.protocol === 'https:' + const accessTokenExpiresAt = new Date((data.created_at + data.expires_in) * 1000) + + setAccountCookie( + cookies, + { + userId: data.user.id, + username: data.user.username, + token: data.access_token, + role: data.user.role + }, + { + secure, + expires: accessTokenExpiresAt + } + ) + + setRefreshCookie(cookies, data.refresh_token, { secure }) + + return json({ + success: true, + username: data.user.username, + expires_at: accessTokenExpiresAt.toISOString() + }) +} diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts new file mode 100644 index 00000000..710ee296 --- /dev/null +++ b/src/routes/login/+page.server.ts @@ -0,0 +1,34 @@ +import type { Actions, PageServerLoad } from './$types' +import { fail, redirect } from '@sveltejs/kit' + +export const load: PageServerLoad = async ({ locals, url }) => { + if (locals.session.isAuthenticated) { + throw redirect(302, url.searchParams.get('next') ?? '/me') + } + return {} +} + +export const actions: Actions = { + default: async ({ request, fetch, url }) => { + const form = await request.formData() + const email = String(form.get('email') ?? '') + const password = String(form.get('password') ?? '') + + if (!email || !password) { + return fail(400, { error: 'Email and password are required', email }) + } + + const res = await fetch('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, grant_type: 'password' }) + }) + + if (res.ok) { + throw redirect(303, url.searchParams.get('next') ?? '/me') + } + + const j = await res.json().catch(() => ({})) + return fail(res.status, { error: j.error ?? 'Login failed', email }) + } +} diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 00000000..dc6f30a6 --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,23 @@ + + +

Login

+ +
+ + + + + {#if form?.error} +

{form.error}

+ {/if} + + +
diff --git a/src/routes/me/+page.server.ts b/src/routes/me/+page.server.ts new file mode 100644 index 00000000..e9a76175 --- /dev/null +++ b/src/routes/me/+page.server.ts @@ -0,0 +1,15 @@ +import type { PageServerLoad } from './$types' +import { redirect } from '@sveltejs/kit' + +export const load: PageServerLoad = async ({ parent }) => { + const { isAuthenticated, account, currentUser } = await parent() + + if (!isAuthenticated) { + throw redirect(302, '/login?next=/me') + } + + return { + account, + user: currentUser + } +} diff --git a/src/routes/me/+page.svelte b/src/routes/me/+page.svelte new file mode 100644 index 00000000..b1ecd5e7 --- /dev/null +++ b/src/routes/me/+page.svelte @@ -0,0 +1,31 @@ + + +

Welcome, {data.account.username}!

+ +
+

Account

+ + +
+

Preferences

+
    +
  • Language: {data.user.language}
  • +
  • Theme: {data.user.theme}
  • +
  • Gender: {data.user.gender}
  • +
  • Element: {data.user.element}
  • +
  • Picture: {data.user.picture}
  • +
+
+ +
+ +
+