feat(auth): add user profile, phone login, and account deletion

This commit is contained in:
iu
2026-10-12 14:26:32 +03:00
parent 1cb9aaf198
commit 86aff695df
46 changed files with 2893 additions and 134 deletions
+12 -4
View File
@@ -86,19 +86,27 @@ export async function api<T = unknown>(path: string, opts: RequestOptions = {}):
}
export const authApi = {
register: (email: string, password: string, name: string) =>
register: (email: string, phone: string, password: string, name: string) =>
api<{ user: User; tokens: Tokens }>('/auth/register', {
method: 'POST',
body: { email, password, name }
body: { email, phone, password, name }
}),
login: (email: string, password: string) =>
login: (identifier: string, password: string) =>
api<{ user: User; tokens: Tokens }>('/auth/login', {
method: 'POST',
body: { email, password }
body: { identifier, password }
}),
logout: (refresh_token: string) =>
api('/auth/logout', { method: 'POST', body: { refresh_token } }),
me: () => api<{ user: User; preferences?: Preferences }>('/auth/me'),
updateProfile: (name: string) =>
api<{ user: User }>('/auth/me', { method: 'PATCH', body: { name } }),
changePassword: (current_password: string, new_password: string) =>
api('/auth/me/password', { method: 'POST', body: { current_password, new_password } }),
addIdentifier: (email: string, phone: string) =>
api<{ user: User }>('/auth/me/identifiers', { method: 'POST', body: { email, phone } }),
deleteAccount: (password: string) =>
api('/auth/me', { method: 'DELETE', body: { password } }),
savePreferences: (preferences: Preferences) =>
api<{ preferences: Preferences }>('/auth/me/preferences', {
method: 'PATCH',
+8 -1
View File
@@ -133,7 +133,14 @@
</button>
{/if}
{#if auth.isAuthenticated}
<span class="text-text-muted hidden text-xs sm:inline">{auth.user?.name ?? ''}</span>
<a
href="/profile"
class="text-text-muted hover:text-text inline-flex items-center gap-1 text-xs"
title="Профиль"
>
<Icon name="settings" class="size-4" />
<span class="hidden sm:inline">{auth.user?.name ?? 'Профиль'}</span>
</a>
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center gap-1 text-xs"
+2 -1
View File
@@ -2,7 +2,8 @@ import type { Preferences } from '$lib/types';
export interface User {
id: string;
email: string;
email?: string;
phone?: string;
name: string;
avatar_url?: string;
preferences?: Preferences;
+1
View File
@@ -10,6 +10,7 @@ export interface Preferences {
export interface User {
id: string;
email?: string;
phone?: string;
name: string;
avatar_url?: string;
preferences?: Preferences;
@@ -0,0 +1,226 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authApi } from '$lib/api';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
let user = $state(auth.user);
let name = $state(auth.user?.name ?? '');
let newEmail = $state('');
let newPhone = $state('');
let currentPassword = $state('');
let newPassword = $state('');
let deletePassword = $state('');
let error = $state('');
let notice = $state('');
$effect(() => {
load();
});
async function load() {
try {
const data = await authApi.me();
user = data.user;
auth.setUser(data.user);
name = data.user.name;
} catch (e) {
error = (e as Error).message;
}
}
function applyUser(updated: typeof user) {
user = updated;
if (updated) auth.setUser(updated);
}
async function saveName(ev: Event) {
ev.preventDefault();
error = '';
notice = '';
try {
const data = await authApi.updateProfile(name);
applyUser(data.user);
notice = 'Имя сохранено';
} catch (e) {
error = (e as Error).message;
}
}
async function addIdentifier(ev: Event) {
ev.preventDefault();
error = '';
notice = '';
try {
const data = await authApi.addIdentifier(newEmail, newPhone);
applyUser(data.user);
newEmail = '';
newPhone = '';
notice = 'Идентификатор добавлен';
} catch (e) {
error = (e as Error).message;
}
}
async function changePassword(ev: Event) {
ev.preventDefault();
error = '';
notice = '';
try {
await authApi.changePassword(currentPassword, newPassword);
auth.clear();
goto('/login');
} catch (e) {
error = (e as Error).message;
}
}
async function removeAccount() {
error = '';
if (!confirm('Удалить аккаунт со всем содержимым? Действие необратимо.')) return;
try {
await authApi.deleteAccount(deletePassword);
auth.clear();
goto('/login');
} catch (e) {
error = (e as Error).message;
}
}
</script>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Профиль</h1>
<a href="/" class="text-primary inline-flex items-center gap-1 text-sm">
<Icon name="arrow_back" class="size-4" />
К альбомам
</a>
</div>
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
{#if notice}<p class="text-success mb-4 text-sm">{notice}</p>{/if}
<div class="border-border bg-bg-elevated mb-6 rounded-xl border p-4">
<h2 class="mb-2 font-semibold">Идентификаторы</h2>
<ul class="text-text-muted space-y-1 text-sm">
<li>Email: {user?.email || '—'}</li>
<li>Телефон: {user?.phone || '—'}</li>
</ul>
</div>
<form class="border-border bg-bg-elevated mb-6 space-y-3 rounded-xl border p-4" onsubmit={saveName}>
<h2 class="font-semibold">Имя</h2>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Отображаемое имя</span>
<input
bind:value={name}
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<button
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
type="submit"
>
<Icon name="save" class="size-4" />
Сохранить
</button>
</form>
{#if !user?.email || !user?.phone}
<form
class="border-border bg-bg-elevated mb-6 space-y-3 rounded-xl border p-4"
onsubmit={addIdentifier}
>
<h2 class="font-semibold">Добавить идентификатор</h2>
{#if !user?.email}
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Email</span>
<input
bind:value={newEmail}
type="email"
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
{/if}
{#if !user?.phone}
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Телефон (E.164)</span>
<input
bind:value={newPhone}
type="tel"
placeholder="+15551234567"
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
{/if}
<p class="text-text-muted text-xs">Заполните одно поле — заданный идентификатор изменить нельзя.</p>
<button
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
type="submit"
>
<Icon name="add" class="size-4" />
Добавить
</button>
</form>
{:else}
<div class="border-border bg-bg-elevated mb-6 rounded-xl border p-4">
<h2 class="mb-2 font-semibold">Добавить идентификатор</h2>
<p class="text-text-muted text-sm">Оба идентификатора заданы.</p>
</div>
{/if}
<form
class="border-border bg-bg-elevated mb-6 space-y-3 rounded-xl border p-4"
onsubmit={changePassword}
>
<h2 class="font-semibold">Смена пароля</h2>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Текущий пароль</span>
<input
bind:value={currentPassword}
type="password"
required
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Новый пароль</span>
<input
bind:value={newPassword}
type="password"
required
minlength="8"
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<p class="text-text-muted text-xs">После смены пароля все сессии будут завершены.</p>
<button
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
type="submit"
>
<Icon name="save" class="size-4" />
Сменить пароль
</button>
</form>
<div class="border-danger/50 bg-bg-elevated space-y-3 rounded-xl border p-4">
<h2 class="text-danger font-semibold">Удаление аккаунта</h2>
<p class="text-text-muted text-sm">
Аккаунт и всё его содержимое удаляются немедленно и необратимо.
</p>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Подтвердите пароль</span>
<input
bind:value={deletePassword}
type="password"
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<button
type="button"
class="border-danger text-danger inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
onclick={removeAccount}
>
<Icon name="delete_forever" class="size-4" />
Удалить аккаунт
</button>
</div>
+35 -11
View File
@@ -4,7 +4,9 @@
import { auth } from '$lib/stores/auth.svelte';
let mode = $state<'login' | 'register'>('login');
let identifier = $state('');
let email = $state('');
let phone = $state('');
let password = $state('');
let name = $state('');
let error = $state('');
@@ -13,12 +15,16 @@
async function submit(ev: Event) {
ev.preventDefault();
error = '';
if (mode === 'register' && !email.trim() && !phone.trim()) {
error = 'Укажите email или телефон';
return;
}
busy = true;
try {
const data =
mode === 'login'
? await authApi.login(email, password)
: await authApi.register(email, password, name);
? await authApi.login(identifier, password)
: await authApi.register(email, phone, password, name);
auth.setTokens(data.tokens);
auth.setUser(data.user);
goto('/');
@@ -43,16 +49,34 @@
class="border-border bg-bg-elevated text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Email (необязательно)</span>
<input
bind:value={email}
type="email"
class="border-border bg-bg-elevated text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Телефон (необязательно)</span>
<input
bind:value={phone}
type="tel"
placeholder="+15551234567"
class="border-border bg-bg-elevated text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<p class="text-text-muted text-xs">Нужен хотя бы один из email или телефона.</p>
{:else}
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Email или телефон</span>
<input
bind:value={identifier}
required
class="border-border bg-bg-elevated text-text w-full rounded-lg border px-3 py-2"
/>
</label>
{/if}
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Email</span>
<input
bind:value={email}
type="email"
required
class="border-border bg-bg-elevated text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Пароль</span>
<input