authorizations management
This commit is contained in:
parent
fcfe39e78a
commit
3bf4c7ce04
@ -17,3 +17,5 @@ EMAIL_SMTP_SECURE=false
|
|||||||
EMAIL_SMTP_USER=
|
EMAIL_SMTP_USER=
|
||||||
EMAIL_SMTP_PASS=
|
EMAIL_SMTP_PASS=
|
||||||
REGISTRATIONS=true
|
REGISTRATIONS=true
|
||||||
|
ADDRESS_HEADER=X-Forwarded-For
|
||||||
|
XFF_DEPTH=1
|
||||||
|
@ -19,6 +19,10 @@
|
|||||||
|
|
||||||
&.with-actions {
|
&.with-actions {
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
||||||
|
& .actions-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
16
src/lib/components/container/TitleRow.svelte
Normal file
16
src/lib/components/container/TitleRow.svelte
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<div class="title-row">
|
||||||
|
<slot />
|
||||||
|
<slot name="action" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.title-row > :global(*):first-child {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
13
src/lib/components/form/FormErrors.svelte
Normal file
13
src/lib/components/form/FormErrors.svelte
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import Alert from '../Alert.svelte';
|
||||||
|
|
||||||
|
export let prefix: string;
|
||||||
|
export let errors: string[] = [];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if errors.length}
|
||||||
|
{#each errors as error}
|
||||||
|
<Alert type="error">{$t(`${prefix}.${error}`)}</Alert>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Manage your account",
|
"title": "Manage your account",
|
||||||
|
"altTitle": "Account management",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"usernameHint": "Only the English alphabet, numbers and _-. are allowed.",
|
"usernameHint": "Only the English alphabet, numbers and _-. are allowed.",
|
||||||
"displayName": "Display Name",
|
"displayName": "Display Name",
|
||||||
@ -8,6 +9,7 @@
|
|||||||
"currentEmail": "Current email address",
|
"currentEmail": "Current email address",
|
||||||
"email": "Email address",
|
"email": "Email address",
|
||||||
"newEmail": "New email address",
|
"newEmail": "New email address",
|
||||||
|
"emailHint": "Hint",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"repeatPassword": "Repeat password",
|
"repeatPassword": "Repeat password",
|
||||||
"changePassword": "Change password",
|
"changePassword": "Change password",
|
||||||
@ -67,7 +69,7 @@
|
|||||||
},
|
},
|
||||||
"otp": {
|
"otp": {
|
||||||
"title": "Two-factor authentication",
|
"title": "Two-factor authentication",
|
||||||
"enabled": "Two-factor authentication is enabled",
|
"enabled": "Two-factor authentication is enabled.",
|
||||||
"disabled": "Your account does not have two-factor authentication enabled.",
|
"disabled": "Your account does not have two-factor authentication enabled.",
|
||||||
"activated": "Two-factor authentication has been activated successfully!",
|
"activated": "Two-factor authentication has been activated successfully!",
|
||||||
"deactivated": "Two-factor authentication has been deactivated successfully.",
|
"deactivated": "Two-factor authentication has been deactivated successfully.",
|
||||||
@ -77,5 +79,13 @@
|
|||||||
"retry": "Try again",
|
"retry": "Try again",
|
||||||
"activate": "Set up two factor authentication",
|
"activate": "Set up two factor authentication",
|
||||||
"deactivate": "Deactivate"
|
"deactivate": "Deactivate"
|
||||||
|
},
|
||||||
|
"authorizations": {
|
||||||
|
"title": "Authorized applications",
|
||||||
|
"description": "These applications are authorized automatically when requested, provided you have already consented to the information they require. You should revoke any applications you do not recognize.",
|
||||||
|
"warning": "By revoking the authorization, you may be prompted to authorize the application again in the future. This does NOT ensure that your information is deleted by any third-party applications in question. Please contact each application's owner individually if you wish to remove your information from their servers.",
|
||||||
|
"website": "Visit website",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"none": "You currently do not have any authorized applications."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -100,7 +100,7 @@ export class OAuth2Tokens {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async wipeExpiredTokens() {
|
static async wipeExpiredTokens() {
|
||||||
await db.execute(sql`DELETE FROM o_auth2_token WHERE expires_at < NOW()`);
|
await db.execute(sql`DELETE FROM ${oauth2Token} WHERE ${oauth2Token.expires_at} < NOW()`);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async remove(token: OAuth2Token) {
|
static async remove(token: OAuth2Token) {
|
||||||
|
@ -2,6 +2,7 @@ import {
|
|||||||
db,
|
db,
|
||||||
oauth2Client,
|
oauth2Client,
|
||||||
oauth2ClientAuthorization,
|
oauth2ClientAuthorization,
|
||||||
|
oauth2ClientUrl,
|
||||||
type OAuth2Client,
|
type OAuth2Client,
|
||||||
type User
|
type User
|
||||||
} from '$lib/server/drizzle';
|
} from '$lib/server/drizzle';
|
||||||
@ -36,7 +37,7 @@ export class OAuth2Users {
|
|||||||
})?.length;
|
})?.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async saveConsent(user: User, client: OAuth2Client, scopes: string | string[]) {
|
static async saveConsent(subject: User, client: OAuth2Client, scopes: string | string[]) {
|
||||||
const normalized = OAuth2Clients.splitScope(scopes);
|
const normalized = OAuth2Clients.splitScope(scopes);
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
@ -44,7 +45,7 @@ export class OAuth2Users {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(oauth2ClientAuthorization.clientId, client.id),
|
eq(oauth2ClientAuthorization.clientId, client.id),
|
||||||
eq(oauth2ClientAuthorization.userId, user.id)
|
eq(oauth2ClientAuthorization.userId, subject.id)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@ -64,21 +65,23 @@ export class OAuth2Users {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db.insert(oauth2ClientAuthorization).values({
|
||||||
.insert(oauth2ClientAuthorization)
|
userId: subject.id,
|
||||||
.values({ userId: user.id, clientId: client.id, scope: OAuth2Clients.joinScope(normalized) });
|
clientId: client.id,
|
||||||
|
scope: OAuth2Clients.joinScope(normalized)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static async revokeConsent(user: User, clientId: string) {
|
static async revokeConsent(subject: User, clientId: string) {
|
||||||
const client = await OAuth2Clients.fetchById(clientId);
|
const client = await OAuth2Clients.fetchById(clientId);
|
||||||
if (!client) return false;
|
if (!client) return false;
|
||||||
|
|
||||||
await OAuth2Tokens.wipeClientTokens(client, user);
|
await OAuth2Tokens.wipeClientTokens(client, subject);
|
||||||
await db
|
await db
|
||||||
.delete(oauth2ClientAuthorization)
|
.delete(oauth2ClientAuthorization)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(oauth2ClientAuthorization.userId, user.id),
|
eq(oauth2ClientAuthorization.userId, subject.id),
|
||||||
eq(oauth2ClientAuthorization.clientId, client.id)
|
eq(oauth2ClientAuthorization.clientId, client.id)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@ -86,24 +89,33 @@ export class OAuth2Users {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async issueIdToken(user: User, client: OAuth2Client, scope: string[], nonce?: string) {
|
static async listAuthorizations(subject: User) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(oauth2Client)
|
||||||
|
.innerJoin(oauth2ClientAuthorization, eq(oauth2ClientAuthorization.clientId, oauth2Client.id))
|
||||||
|
.leftJoin(oauth2ClientUrl, eq(oauth2ClientUrl.clientId, oauth2Client.id))
|
||||||
|
.where(and(eq(oauth2ClientAuthorization.userId, subject.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
static async issueIdToken(subject: User, client: OAuth2Client, scope: string[], nonce?: string) {
|
||||||
const userData: Record<string, unknown> = {
|
const userData: Record<string, unknown> = {
|
||||||
name: user.display_name,
|
name: subject.display_name,
|
||||||
preferred_username: user.username,
|
preferred_username: subject.username,
|
||||||
nickname: user.display_name,
|
nickname: subject.display_name,
|
||||||
updated_at: user.updated_at,
|
updated_at: subject.updated_at,
|
||||||
nonce
|
nonce
|
||||||
};
|
};
|
||||||
|
|
||||||
if (scope.includes('email')) {
|
if (scope.includes('email')) {
|
||||||
userData.email = user.email;
|
userData.email = subject.email;
|
||||||
userData.email_verified = true;
|
userData.email_verified = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope.includes('picture') && user.pictureId) {
|
if (scope.includes('picture') && subject.pictureId) {
|
||||||
userData.picture = `${PUBLIC_URL}/api/avatar/${user.uuid}`;
|
userData.picture = `${PUBLIC_URL}/api/avatar/${subject.uuid}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return JWT.issue(userData, user.uuid, client.client_id);
|
return JWT.issue(userData, subject.uuid, client.client_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { and, eq, gt, or, sql } from 'drizzle-orm';
|
import { and, eq, or, sql } from 'drizzle-orm';
|
||||||
import { db, user, userToken, type User } from '../drizzle';
|
import { db, user, type User } from '../drizzle';
|
||||||
import type { UserSession } from './types';
|
import type { UserSession } from './types';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { CryptoUtils } from '../crypto-utils';
|
import { CryptoUtils } from '../crypto-utils';
|
||||||
@ -92,17 +92,7 @@ export class Users {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async getActivationToken(token: string) {
|
static async getActivationToken(token: string) {
|
||||||
const [returnedToken] = await db
|
const returnedToken = await UserTokens.getByToken(token, 'activation');
|
||||||
.select({ id: userToken.id, token: userToken.token, userId: userToken.userId })
|
|
||||||
.from(userToken)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userToken.token, token),
|
|
||||||
eq(userToken.type, 'activation'),
|
|
||||||
gt(userToken.expires_at, new Date())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!returnedToken?.userId) return undefined;
|
if (!returnedToken?.userId) return undefined;
|
||||||
|
|
||||||
const [userInfo] = await db
|
const [userInfo] = await db
|
||||||
@ -124,23 +114,6 @@ export class Users {
|
|||||||
await UserTokens.remove(token);
|
await UserTokens.remove(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getPasswordToken(token: string) {
|
|
||||||
return db.query.userToken.findFirst({
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
token: true
|
|
||||||
},
|
|
||||||
where: and(
|
|
||||||
eq(userToken.token, token),
|
|
||||||
eq(userToken.type, 'password'),
|
|
||||||
gt(userToken.expires_at, new Date())
|
|
||||||
),
|
|
||||||
with: {
|
|
||||||
user: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static async register({
|
static async register({
|
||||||
username,
|
username,
|
||||||
displayName,
|
displayName,
|
||||||
@ -240,6 +213,12 @@ export class Users {
|
|||||||
await UserTokens.remove(token);
|
await UserTokens.remove(token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static anonymizeEmail(email: string) {
|
||||||
|
const [name, domain] = email.split('@');
|
||||||
|
const namePart = `${name.charAt(0)}${''.padStart(name.length - 2, '*')}${name.charAt(name.length - 1)}`;
|
||||||
|
return `${namePart}@${domain}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export * from './totp';
|
export * from './totp';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { and, eq, gt, isNull, or } from 'drizzle-orm';
|
import { and, eq, gt, isNull, or, sql } from 'drizzle-orm';
|
||||||
import { CryptoUtils } from '../crypto-utils';
|
import { CryptoUtils } from '../crypto-utils';
|
||||||
import { db, userToken, type UserToken } from '../drizzle';
|
import { db, userToken, type UserToken } from '../drizzle';
|
||||||
|
|
||||||
@ -40,4 +40,8 @@ export class UserTokens {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
return returned;
|
return returned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async wipeExpiredTokens() {
|
||||||
|
await db.execute(sql`DELETE FROM ${userToken} WHERE ${userToken.expires_at} < NOW()`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
23
src/routes/+error.svelte
Normal file
23
src/routes/+error.svelte
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<script>
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import MainContainer from '$lib/components/container/MainContainer.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<MainContainer>
|
||||||
|
<div class="centered">
|
||||||
|
<h1>{$page.status}</h1>
|
||||||
|
<p>{$page.error?.message}</p>
|
||||||
|
{#if $page.status !== 404}
|
||||||
|
<Button on:click={() => invalidateAll()} variant="link">Go back</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</MainContainer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.centered {
|
||||||
|
font-size: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
@ -194,6 +194,7 @@ export async function load({ locals, url }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
user: userInfo,
|
user: userInfo,
|
||||||
|
email: Users.anonymizeEmail(currentUser.email),
|
||||||
otpEnabled,
|
otpEnabled,
|
||||||
hasAvatar: !!currentUser.pictureId,
|
hasAvatar: !!currentUser.pictureId,
|
||||||
updateRef
|
updateRef
|
||||||
|
@ -7,15 +7,16 @@
|
|||||||
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import Alert from '$lib/components/Alert.svelte';
|
import Alert from '$lib/components/Alert.svelte';
|
||||||
import ViewColumn from '$lib/components/ColumnView.svelte';
|
import ViewColumn from '$lib/components/container/ColumnView.svelte';
|
||||||
import MainContainer from '$lib/components/MainContainer.svelte';
|
import MainContainer from '$lib/components/container/MainContainer.svelte';
|
||||||
import SplitView from '$lib/components/SplitView.svelte';
|
import SplitView from '$lib/components/container/SplitView.svelte';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import type { SubmitFunction } from '@sveltejs/kit';
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
|
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
|
||||||
import AvatarModal from '$lib/components/avatar/AvatarModal.svelte';
|
import AvatarModal from '$lib/components/avatar/AvatarModal.svelte';
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
||||||
|
import FormErrors from '$lib/components/form/FormErrors.svelte';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
export let form: ActionData;
|
export let form: ActionData;
|
||||||
@ -62,105 +63,106 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2>{$t('account.title')}</h2>
|
<h2>{$t('account.title')}</h2>
|
||||||
|
|
||||||
<form action="?/update" method="POST" use:enhance={enhanceFn}>
|
<ViewColumn>
|
||||||
<FormWrapper>
|
<form action="?/update" method="POST" use:enhance={enhanceFn}>
|
||||||
{#if form?.success}<Alert type="success">{$t('account.changeSuccess')}</Alert>{/if}
|
<FormWrapper>
|
||||||
{#if errors.length}
|
{#if form?.success}<Alert type="success">{$t('account.changeSuccess')}</Alert>{/if}
|
||||||
{#each errors as error}
|
<FormErrors {errors} prefix="account.errors" />
|
||||||
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if form?.otpRequired}
|
{#if form?.otpRequired}
|
||||||
<!-- Two-factor code request -->
|
<!-- Two-factor code request -->
|
||||||
<FormSection title={$t('account.login.otp')}>
|
<FormSection title={$t('account.login.otp')}>
|
||||||
<input name="challenge" value={form.otpRequired} type="hidden" />
|
<input name="challenge" value={form.otpRequired} type="hidden" />
|
||||||
|
<FormControl>
|
||||||
|
<label for="form-otpCode">{$t('account.login.otpCode')}</label>
|
||||||
|
<!-- svelte-ignore a11y-autofocus -->
|
||||||
|
<input id="form-otpCode" name="otpCode" autocomplete="off" autofocus />
|
||||||
|
</FormControl>
|
||||||
|
</FormSection>
|
||||||
|
{:else}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<label for="form-otpCode">{$t('account.login.otpCode')}</label>
|
<label for="form-username">{$t('account.username')}</label>
|
||||||
<!-- svelte-ignore a11y-autofocus -->
|
|
||||||
<input id="form-otpCode" name="otpCode" autocomplete="off" autofocus />
|
|
||||||
</FormControl>
|
|
||||||
</FormSection>
|
|
||||||
{:else}
|
|
||||||
<FormControl>
|
|
||||||
<label for="form-username">{$t('account.username')}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
disabled
|
|
||||||
value={data.user.username}
|
|
||||||
id="form-username"
|
|
||||||
autocomplete="username"
|
|
||||||
bind:this={usernameRef}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<label for="form-displayName">{$t('account.displayName')}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
autocomplete="nickname"
|
|
||||||
name="displayName"
|
|
||||||
value={form?.displayName || data.user.name}
|
|
||||||
id="form-displayName"
|
|
||||||
bind:this={displayRef}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormSection title={$t('account.changeEmail')}>
|
|
||||||
<FormControl>
|
|
||||||
<label for="form-currentEmail">{$t('account.currentEmail')}</label>
|
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="text"
|
||||||
name="currentEmail"
|
disabled
|
||||||
id="form-currentEmail"
|
value={data.user.username}
|
||||||
autocomplete="email"
|
id="form-username"
|
||||||
|
autocomplete="username"
|
||||||
|
bind:this={usernameRef}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<label for="form-newEmail">{$t('account.newEmail')}</label>
|
<label for="form-displayName">{$t('account.displayName')}</label>
|
||||||
<input type="email" name="newEmail" id="form-newEmail" autocomplete="email" />
|
|
||||||
</FormControl>
|
|
||||||
</FormSection>
|
|
||||||
|
|
||||||
<FormSection title={$t('account.changePassword')}>
|
|
||||||
<FormControl>
|
|
||||||
<label for="form-currentPassword">{$t('account.currentPassword')}</label>
|
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="text"
|
||||||
autocomplete="current-password"
|
autocomplete="nickname"
|
||||||
name="currentPassword"
|
name="displayName"
|
||||||
id="form-currentPassword"
|
value={form?.displayName || data.user.name}
|
||||||
|
id="form-displayName"
|
||||||
|
bind:this={displayRef}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl>
|
<FormSection title={$t('account.changeEmail')}>
|
||||||
<label for="form-newPassword">{$t('account.newPassword')}</label>
|
<FormControl>
|
||||||
<input
|
<label for="form-currentEmail">{$t('account.currentEmail')}</label>
|
||||||
type="password"
|
<input
|
||||||
autocomplete="new-password"
|
type="email"
|
||||||
name="newPassword"
|
name="currentEmail"
|
||||||
id="form-newPassword"
|
id="form-currentEmail"
|
||||||
aria-describedby="new-password-hint"
|
autocomplete="email"
|
||||||
/>
|
/>
|
||||||
<span id="new-password-hint">{$t('account.passwordHint')}</span>
|
<span>{$t('account.emailHint')}: {data.email}</span>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<label for="form-repeatPassword">{$t('account.repeatNewPassword')}</label>
|
<label for="form-newEmail">{$t('account.newEmail')}</label>
|
||||||
<input
|
<input type="email" name="newEmail" id="form-newEmail" autocomplete="email" />
|
||||||
type="password"
|
</FormControl>
|
||||||
autocomplete="new-password"
|
</FormSection>
|
||||||
name="repeatPassword"
|
|
||||||
id="form-repeatPassword"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormSection>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Button variant="primary" type="submit">{$t('account.submit')}</Button>
|
<FormSection title={$t('account.changePassword')}>
|
||||||
</FormWrapper>
|
<FormControl>
|
||||||
</form>
|
<label for="form-currentPassword">{$t('account.currentPassword')}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
name="currentPassword"
|
||||||
|
id="form-currentPassword"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<label for="form-newPassword">{$t('account.newPassword')}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
name="newPassword"
|
||||||
|
id="form-newPassword"
|
||||||
|
aria-describedby="new-password-hint"
|
||||||
|
/>
|
||||||
|
<span id="new-password-hint">{$t('account.passwordHint')}</span>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<label for="form-repeatPassword">{$t('account.repeatNewPassword')}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
name="repeatPassword"
|
||||||
|
id="form-repeatPassword"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormSection>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button variant="primary" type="submit">{$t('account.submit')}</Button>
|
||||||
|
</FormWrapper>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<LogoutButton />
|
||||||
|
</ViewColumn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ViewColumn slot="side">
|
<ViewColumn slot="side">
|
||||||
@ -168,9 +170,11 @@
|
|||||||
<h3>{$t('account.avatar.title')}</h3>
|
<h3>{$t('account.avatar.title')}</h3>
|
||||||
<AvatarCard src={`/api/avatar/${data.user.uuid}?t=${data.updateRef}`} alt={data.user.name}>
|
<AvatarCard src={`/api/avatar/${data.user.uuid}?t=${data.updateRef}`} alt={data.user.name}>
|
||||||
<ViewColumn>
|
<ViewColumn>
|
||||||
<Button variant="primary" on:click={() => ($showAvatarModal = true)}
|
<div>
|
||||||
>{$t('account.avatar.change')}</Button
|
<Button variant="primary" on:click={() => ($showAvatarModal = true)}
|
||||||
>
|
>{$t('account.avatar.change')}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
{#if data.hasAvatar}
|
{#if data.hasAvatar}
|
||||||
<form action="?/removeAvatar" method="POST" use:enhance>
|
<form action="?/removeAvatar" method="POST" use:enhance>
|
||||||
<Button variant="link" type="submit">{$t('account.avatar.remove')}</Button>
|
<Button variant="link" type="submit">{$t('account.avatar.remove')}</Button>
|
||||||
@ -189,9 +193,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<a href="/account/two-factor">{$t('common.manage')}</a>
|
<a href="/account/two-factor">{$t('common.manage')}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>{$t('account.authorizations.title')}</h3>
|
||||||
|
<a href="/account/authorizations">{$t('common.manage')}</a>
|
||||||
|
</div>
|
||||||
</ViewColumn>
|
</ViewColumn>
|
||||||
</SplitView>
|
</SplitView>
|
||||||
<LogoutButton />
|
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
|
|
||||||
<AvatarModal show={showAvatarModal} />
|
<AvatarModal show={showAvatarModal} />
|
||||||
|
63
src/routes/account/authorizations/+page.server.ts
Normal file
63
src/routes/account/authorizations/+page.server.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { OAuth2Clients, OAuth2Users } from '$lib/server/oauth2/index.js';
|
||||||
|
import { Users } from '$lib/server/users';
|
||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
interface MappedClient {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
scope: string[];
|
||||||
|
website?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
revoke: async ({ locals, url }) => {
|
||||||
|
const currentUser = await Users.getBySession(locals.session.data?.user);
|
||||||
|
if (!currentUser) {
|
||||||
|
await locals.session.destroy();
|
||||||
|
return redirect(301, `/login?redirectTo=${encodeURIComponent(url.pathname)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = url.searchParams.get('client') as string;
|
||||||
|
if (!clientId) {
|
||||||
|
return fail(400, { invalid: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await OAuth2Users.revokeConsent(currentUser, clientId);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const load = async ({ locals, url }) => {
|
||||||
|
const currentUser = await Users.getBySession(locals.session.data?.user);
|
||||||
|
if (!currentUser) {
|
||||||
|
await locals.session.destroy();
|
||||||
|
return redirect(301, `/login?redirectTo=${encodeURIComponent(url.pathname)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entites = await OAuth2Users.listAuthorizations(currentUser);
|
||||||
|
const items = entites.reduce<MappedClient[]>((accum, entry) => {
|
||||||
|
const existing = accum.find((item) => item.id === entry.o_auth2_client.client_id);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.website && entry.o_auth2_client_url?.type === 'website') {
|
||||||
|
existing.website = entry.o_auth2_client_url.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return accum;
|
||||||
|
}
|
||||||
|
|
||||||
|
accum.push({
|
||||||
|
id: entry.o_auth2_client.client_id,
|
||||||
|
title: entry.o_auth2_client.title,
|
||||||
|
description: entry.o_auth2_client.description,
|
||||||
|
scope: OAuth2Clients.splitScope(entry.o_auth2_client_authorization.scope || ''),
|
||||||
|
website:
|
||||||
|
entry.o_auth2_client_url?.type === 'website' ? entry.o_auth2_client_url.url : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
return accum;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
};
|
100
src/routes/account/authorizations/+page.svelte
Normal file
100
src/routes/account/authorizations/+page.svelte
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { assets } from '$app/paths';
|
||||||
|
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
|
||||||
|
import ColumnView from '$lib/components/container/ColumnView.svelte';
|
||||||
|
import MainContainer from '$lib/components/container/MainContainer.svelte';
|
||||||
|
import TitleRow from '$lib/components/container/TitleRow.svelte';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t('account.authorizations.title')} - {PUBLIC_SITE_NAME}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<MainContainer>
|
||||||
|
<TitleRow>
|
||||||
|
<h1>{PUBLIC_SITE_NAME}</h1>
|
||||||
|
<a href="/account">{$t('account.altTitle')}</a>
|
||||||
|
</TitleRow>
|
||||||
|
|
||||||
|
<h2>{$t('account.authorizations.title')}</h2>
|
||||||
|
|
||||||
|
<ColumnView>
|
||||||
|
<p>{$t('account.authorizations.description')}</p>
|
||||||
|
<p>{$t('account.authorizations.warning')}</p>
|
||||||
|
|
||||||
|
<div class="auth-list">
|
||||||
|
{#each data.items as client}
|
||||||
|
<!-- TODO: client pictures -->
|
||||||
|
<AvatarCard src={`${assets}/application.png`} alt={client.title}>
|
||||||
|
<div class="card-row">
|
||||||
|
<div class="card-inner">
|
||||||
|
<span class="card-display-name">{client.title}</span>
|
||||||
|
<span class="card-user-name">{client.description}</span>
|
||||||
|
|
||||||
|
{#if client.website}
|
||||||
|
<div class="card-links">
|
||||||
|
<a href={client.website} target="_blank" rel="nofollow noreferrer"
|
||||||
|
>{$t(`account.authorizations.website`)}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="card-button">
|
||||||
|
<form action="?/revoke&client={client.id}" method="POST" use:enhance>
|
||||||
|
<Button type="submit" variant="primary"
|
||||||
|
>{$t(`account.authorizations.revoke`)}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AvatarCard>
|
||||||
|
{:else}
|
||||||
|
<div class="auth-list-empty">{$t(`account.authorizations.none`)}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</ColumnView>
|
||||||
|
</MainContainer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.auth-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.card-row {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.card-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.card-button {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.card-display-name {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.card-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.auth-list-empty {
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
@ -2,8 +2,9 @@
|
|||||||
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
||||||
import Alert from '$lib/components/Alert.svelte';
|
import Alert from '$lib/components/Alert.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import ColumnView from '$lib/components/ColumnView.svelte';
|
import ColumnView from '$lib/components/container/ColumnView.svelte';
|
||||||
import MainContainer from '$lib/components/MainContainer.svelte';
|
import MainContainer from '$lib/components/container/MainContainer.svelte';
|
||||||
|
import TitleRow from '$lib/components/container/TitleRow.svelte';
|
||||||
import FormControl from '$lib/components/form/FormControl.svelte';
|
import FormControl from '$lib/components/form/FormControl.svelte';
|
||||||
import FormSection from '$lib/components/form/FormSection.svelte';
|
import FormSection from '$lib/components/form/FormSection.svelte';
|
||||||
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||||
@ -19,7 +20,11 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<MainContainer>
|
<MainContainer>
|
||||||
<h1>{PUBLIC_SITE_NAME}</h1>
|
<TitleRow>
|
||||||
|
<h1>{PUBLIC_SITE_NAME}</h1>
|
||||||
|
<a href="/account">{$t('account.altTitle')}</a>
|
||||||
|
</TitleRow>
|
||||||
|
|
||||||
<h2>{$t('account.otp.title')}</h2>
|
<h2>{$t('account.otp.title')}</h2>
|
||||||
|
|
||||||
<ColumnView>
|
<ColumnView>
|
||||||
@ -50,17 +55,17 @@
|
|||||||
{:else if form?.invalid}
|
{:else if form?.invalid}
|
||||||
<Alert type="error">{$t('account.errors.otpFailed')}</Alert>
|
<Alert type="error">{$t('account.errors.otpFailed')}</Alert>
|
||||||
<form action="?/{form?.action || 'activate'}" method="POST">
|
<form action="?/{form?.action || 'activate'}" method="POST">
|
||||||
<Button type="submit" variant="link">{$t('account.otp.retry')}</Button>
|
<Button type="submit" variant="primary">{$t('account.otp.retry')}</Button>
|
||||||
</form>
|
</form>
|
||||||
{:else if data?.otpEnabled}
|
{:else if data?.otpEnabled}
|
||||||
<Alert type="success">{$t('account.otp.enabled')}</Alert>
|
<Alert type="success">{$t('account.otp.enabled')}</Alert>
|
||||||
<form action="?/deactivate" method="POST">
|
<form action="?/deactivate" method="POST">
|
||||||
<Button type="submit" variant="link">{$t('account.otp.deactivate')}</Button>
|
<Button type="submit" variant="primary">{$t('account.otp.deactivate')}</Button>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<Alert type="default">{$t('account.otp.disabled')}</Alert>
|
<Alert type="default">{$t('account.otp.disabled')}</Alert>
|
||||||
<form action="?/activate" method="POST">
|
<form action="?/activate" method="POST">
|
||||||
<Button type="submit" variant="link">{$t('account.otp.activate')}</Button>
|
<Button type="submit" variant="primary">{$t('account.otp.activate')}</Button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</ColumnView>
|
</ColumnView>
|
||||||
|
@ -16,8 +16,12 @@ interface LoginChallenge {
|
|||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rainbowTableLimiter = new RateLimiter({
|
||||||
|
IP: [3, '10s']
|
||||||
|
});
|
||||||
|
|
||||||
const limiter = new RateLimiter({
|
const limiter = new RateLimiter({
|
||||||
IP: [6, '45s']
|
IP: [6, 'm']
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@ -54,6 +58,7 @@ export const actions = {
|
|||||||
// Find existing active user
|
// Find existing active user
|
||||||
const loginUser = await Users.getByLogin(email);
|
const loginUser = await Users.getByLogin(email);
|
||||||
if (!loginUser) {
|
if (!loginUser) {
|
||||||
|
if (await rainbowTableLimiter.isLimited(event)) throw error(429);
|
||||||
return fail(400, { email, incorrect: true });
|
return fail(400, { email, incorrect: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,7 +85,8 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Compare user password
|
// Compare user password
|
||||||
if (!loginUser || !password || !(await Users.validatePassword(loginUser, password))) {
|
if (!password || !(await Users.validatePassword(loginUser, password))) {
|
||||||
|
if (await rainbowTableLimiter.isLimited(event)) throw error(429);
|
||||||
return fail(400, { email, incorrect: true });
|
return fail(400, { email, incorrect: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,12 +4,12 @@
|
|||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import Alert from '$lib/components/Alert.svelte';
|
import Alert from '$lib/components/Alert.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import SideContainer from '$lib/components/SideContainer.svelte';
|
import SideContainer from '$lib/components/container/SideContainer.svelte';
|
||||||
import FormControl from '$lib/components/form/FormControl.svelte';
|
import FormControl from '$lib/components/form/FormControl.svelte';
|
||||||
import FormSection from '$lib/components/form/FormSection.svelte';
|
import FormSection from '$lib/components/form/FormSection.svelte';
|
||||||
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import ButtonRow from '$lib/components/ButtonRow.svelte';
|
import ButtonRow from '$lib/components/container/ButtonRow.svelte';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
export let form: ActionData;
|
export let form: ActionData;
|
||||||
|
@ -4,9 +4,10 @@
|
|||||||
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
||||||
import Alert from '$lib/components/Alert.svelte';
|
import Alert from '$lib/components/Alert.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import ColumnView from '$lib/components/ColumnView.svelte';
|
import ColumnView from '$lib/components/container/ColumnView.svelte';
|
||||||
import SideContainer from '$lib/components/SideContainer.svelte';
|
import SideContainer from '$lib/components/container/SideContainer.svelte';
|
||||||
import FormControl from '$lib/components/form/FormControl.svelte';
|
import FormControl from '$lib/components/form/FormControl.svelte';
|
||||||
|
import FormErrors from '$lib/components/form/FormErrors.svelte';
|
||||||
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import type { ActionData, PageData, SubmitFunction } from './$types';
|
import type { ActionData, PageData, SubmitFunction } from './$types';
|
||||||
@ -59,11 +60,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<form action={actionUrl} method="POST" use:enhance={enhanceFn}>
|
<form action={actionUrl} method="POST" use:enhance={enhanceFn}>
|
||||||
<FormWrapper>
|
<FormWrapper>
|
||||||
{#if errors.length}
|
<FormErrors {errors} prefix="account.errors" />
|
||||||
{#each errors as error}
|
|
||||||
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if data.setter}
|
{#if data.setter}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
@ -3,8 +3,8 @@
|
|||||||
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
||||||
import Alert from '$lib/components/Alert.svelte';
|
import Alert from '$lib/components/Alert.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import ColumnView from '$lib/components/ColumnView.svelte';
|
import ColumnView from '$lib/components/container/ColumnView.svelte';
|
||||||
import MainContainer from '$lib/components/MainContainer.svelte';
|
import MainContainer from '$lib/components/container/MainContainer.svelte';
|
||||||
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
|
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
@ -46,6 +46,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="graphic" aria-hidden="true"></div>
|
<div class="graphic" aria-hidden="true"></div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<!-- TODO: client pictures -->
|
||||||
<AvatarCard src={`${assets}/application.png`} alt={data.client.title}>
|
<AvatarCard src={`${assets}/application.png`} alt={data.client.title}>
|
||||||
<div class="card-inner">
|
<div class="card-inner">
|
||||||
<span class="card-display-name">{data.client.title}</span>
|
<span class="card-display-name">{data.client.title}</span>
|
||||||
|
@ -3,14 +3,15 @@
|
|||||||
import type { SubmitFunction } from '@sveltejs/kit';
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
import type { PageData, ActionData } from './$types';
|
import type { PageData, ActionData } from './$types';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import SideContainer from '$lib/components/SideContainer.svelte';
|
import SideContainer from '$lib/components/container/SideContainer.svelte';
|
||||||
import Alert from '$lib/components/Alert.svelte';
|
import Alert from '$lib/components/Alert.svelte';
|
||||||
import ColumnView from '$lib/components/ColumnView.svelte';
|
import ColumnView from '$lib/components/container/ColumnView.svelte';
|
||||||
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||||
import FormControl from '$lib/components/form/FormControl.svelte';
|
import FormControl from '$lib/components/form/FormControl.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import FormSection from '$lib/components/form/FormSection.svelte';
|
import FormSection from '$lib/components/form/FormSection.svelte';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import FormErrors from '$lib/components/form/FormErrors.svelte';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
export let form: ActionData;
|
export let form: ActionData;
|
||||||
@ -54,11 +55,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<form action="" method="POST" use:enhance={enhanceFn}>
|
<form action="" method="POST" use:enhance={enhanceFn}>
|
||||||
<FormWrapper>
|
<FormWrapper>
|
||||||
{#if errors.length}
|
<FormErrors {errors} prefix="account.errors" />
|
||||||
{#each errors as error}
|
|
||||||
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<FormSection required>
|
<FormSection required>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
1
src/routes/soadmin/+layout.svelte
Normal file
1
src/routes/soadmin/+layout.svelte
Normal file
@ -0,0 +1 @@
|
|||||||
|
<slot />
|
Loading…
Reference in New Issue
Block a user