authorizations management
This commit is contained in:
parent
fcfe39e78a
commit
3bf4c7ce04
@ -17,3 +17,5 @@ EMAIL_SMTP_SECURE=false
|
||||
EMAIL_SMTP_USER=
|
||||
EMAIL_SMTP_PASS=
|
||||
REGISTRATIONS=true
|
||||
ADDRESS_HEADER=X-Forwarded-For
|
||||
XFF_DEPTH=1
|
||||
|
@ -19,6 +19,10 @@
|
||||
|
||||
&.with-actions {
|
||||
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",
|
||||
"altTitle": "Account management",
|
||||
"username": "Username",
|
||||
"usernameHint": "Only the English alphabet, numbers and _-. are allowed.",
|
||||
"displayName": "Display Name",
|
||||
@ -8,6 +9,7 @@
|
||||
"currentEmail": "Current email address",
|
||||
"email": "Email address",
|
||||
"newEmail": "New email address",
|
||||
"emailHint": "Hint",
|
||||
"password": "Password",
|
||||
"repeatPassword": "Repeat password",
|
||||
"changePassword": "Change password",
|
||||
@ -67,7 +69,7 @@
|
||||
},
|
||||
"otp": {
|
||||
"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.",
|
||||
"activated": "Two-factor authentication has been activated successfully!",
|
||||
"deactivated": "Two-factor authentication has been deactivated successfully.",
|
||||
@ -77,5 +79,13 @@
|
||||
"retry": "Try again",
|
||||
"activate": "Set up two factor authentication",
|
||||
"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() {
|
||||
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) {
|
||||
|
@ -2,6 +2,7 @@ import {
|
||||
db,
|
||||
oauth2Client,
|
||||
oauth2ClientAuthorization,
|
||||
oauth2ClientUrl,
|
||||
type OAuth2Client,
|
||||
type User
|
||||
} from '$lib/server/drizzle';
|
||||
@ -36,7 +37,7 @@ export class OAuth2Users {
|
||||
})?.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 [existing] = await db
|
||||
.select()
|
||||
@ -44,7 +45,7 @@ export class OAuth2Users {
|
||||
.where(
|
||||
and(
|
||||
eq(oauth2ClientAuthorization.clientId, client.id),
|
||||
eq(oauth2ClientAuthorization.userId, user.id)
|
||||
eq(oauth2ClientAuthorization.userId, subject.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
@ -64,21 +65,23 @@ export class OAuth2Users {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(oauth2ClientAuthorization)
|
||||
.values({ userId: user.id, clientId: client.id, scope: OAuth2Clients.joinScope(normalized) });
|
||||
await db.insert(oauth2ClientAuthorization).values({
|
||||
userId: subject.id,
|
||||
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);
|
||||
if (!client) return false;
|
||||
|
||||
await OAuth2Tokens.wipeClientTokens(client, user);
|
||||
await OAuth2Tokens.wipeClientTokens(client, subject);
|
||||
await db
|
||||
.delete(oauth2ClientAuthorization)
|
||||
.where(
|
||||
and(
|
||||
eq(oauth2ClientAuthorization.userId, user.id),
|
||||
eq(oauth2ClientAuthorization.userId, subject.id),
|
||||
eq(oauth2ClientAuthorization.clientId, client.id)
|
||||
)
|
||||
);
|
||||
@ -86,24 +89,33 @@ export class OAuth2Users {
|
||||
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> = {
|
||||
name: user.display_name,
|
||||
preferred_username: user.username,
|
||||
nickname: user.display_name,
|
||||
updated_at: user.updated_at,
|
||||
name: subject.display_name,
|
||||
preferred_username: subject.username,
|
||||
nickname: subject.display_name,
|
||||
updated_at: subject.updated_at,
|
||||
nonce
|
||||
};
|
||||
|
||||
if (scope.includes('email')) {
|
||||
userData.email = user.email;
|
||||
userData.email = subject.email;
|
||||
userData.email_verified = true;
|
||||
}
|
||||
|
||||
if (scope.includes('picture') && user.pictureId) {
|
||||
userData.picture = `${PUBLIC_URL}/api/avatar/${user.uuid}`;
|
||||
if (scope.includes('picture') && subject.pictureId) {
|
||||
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 { and, eq, gt, or, sql } from 'drizzle-orm';
|
||||
import { db, user, userToken, type User } from '../drizzle';
|
||||
import { and, eq, or, sql } from 'drizzle-orm';
|
||||
import { db, user, type User } from '../drizzle';
|
||||
import type { UserSession } from './types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { CryptoUtils } from '../crypto-utils';
|
||||
@ -92,17 +92,7 @@ export class Users {
|
||||
}
|
||||
|
||||
static async getActivationToken(token: string) {
|
||||
const [returnedToken] = await db
|
||||
.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);
|
||||
const returnedToken = await UserTokens.getByToken(token, 'activation');
|
||||
if (!returnedToken?.userId) return undefined;
|
||||
|
||||
const [userInfo] = await db
|
||||
@ -124,23 +114,6 @@ export class Users {
|
||||
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({
|
||||
username,
|
||||
displayName,
|
||||
@ -240,6 +213,12 @@ export class Users {
|
||||
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';
|
||||
|
@ -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 { db, userToken, type UserToken } from '../drizzle';
|
||||
|
||||
@ -40,4 +40,8 @@ export class UserTokens {
|
||||
.limit(1);
|
||||
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 {
|
||||
user: userInfo,
|
||||
email: Users.anonymizeEmail(currentUser.email),
|
||||
otpEnabled,
|
||||
hasAvatar: !!currentUser.pictureId,
|
||||
updateRef
|
||||
|
@ -7,15 +7,16 @@
|
||||
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Alert from '$lib/components/Alert.svelte';
|
||||
import ViewColumn from '$lib/components/ColumnView.svelte';
|
||||
import MainContainer from '$lib/components/MainContainer.svelte';
|
||||
import SplitView from '$lib/components/SplitView.svelte';
|
||||
import ViewColumn from '$lib/components/container/ColumnView.svelte';
|
||||
import MainContainer from '$lib/components/container/MainContainer.svelte';
|
||||
import SplitView from '$lib/components/container/SplitView.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
|
||||
import AvatarModal from '$lib/components/avatar/AvatarModal.svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
||||
import FormErrors from '$lib/components/form/FormErrors.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
export let form: ActionData;
|
||||
@ -62,105 +63,106 @@
|
||||
<div>
|
||||
<h2>{$t('account.title')}</h2>
|
||||
|
||||
<form action="?/update" method="POST" use:enhance={enhanceFn}>
|
||||
<FormWrapper>
|
||||
{#if form?.success}<Alert type="success">{$t('account.changeSuccess')}</Alert>{/if}
|
||||
{#if errors.length}
|
||||
{#each errors as error}
|
||||
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
|
||||
{/each}
|
||||
{/if}
|
||||
<ViewColumn>
|
||||
<form action="?/update" method="POST" use:enhance={enhanceFn}>
|
||||
<FormWrapper>
|
||||
{#if form?.success}<Alert type="success">{$t('account.changeSuccess')}</Alert>{/if}
|
||||
<FormErrors {errors} prefix="account.errors" />
|
||||
|
||||
{#if form?.otpRequired}
|
||||
<!-- Two-factor code request -->
|
||||
<FormSection title={$t('account.login.otp')}>
|
||||
<input name="challenge" value={form.otpRequired} type="hidden" />
|
||||
{#if form?.otpRequired}
|
||||
<!-- Two-factor code request -->
|
||||
<FormSection title={$t('account.login.otp')}>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<label for="form-username">{$t('account.username')}</label>
|
||||
<input
|
||||
type="email"
|
||||
name="currentEmail"
|
||||
id="form-currentEmail"
|
||||
autocomplete="email"
|
||||
type="text"
|
||||
disabled
|
||||
value={data.user.username}
|
||||
id="form-username"
|
||||
autocomplete="username"
|
||||
bind:this={usernameRef}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<label for="form-newEmail">{$t('account.newEmail')}</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>
|
||||
<label for="form-displayName">{$t('account.displayName')}</label>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
name="currentPassword"
|
||||
id="form-currentPassword"
|
||||
type="text"
|
||||
autocomplete="nickname"
|
||||
name="displayName"
|
||||
value={form?.displayName || data.user.name}
|
||||
id="form-displayName"
|
||||
bind:this={displayRef}
|
||||
/>
|
||||
</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>
|
||||
<FormSection title={$t('account.changeEmail')}>
|
||||
<FormControl>
|
||||
<label for="form-currentEmail">{$t('account.currentEmail')}</label>
|
||||
<input
|
||||
type="email"
|
||||
name="currentEmail"
|
||||
id="form-currentEmail"
|
||||
autocomplete="email"
|
||||
/>
|
||||
<span>{$t('account.emailHint')}: {data.email}</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}
|
||||
<FormControl>
|
||||
<label for="form-newEmail">{$t('account.newEmail')}</label>
|
||||
<input type="email" name="newEmail" id="form-newEmail" autocomplete="email" />
|
||||
</FormControl>
|
||||
</FormSection>
|
||||
|
||||
<Button variant="primary" type="submit">{$t('account.submit')}</Button>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
<FormSection title={$t('account.changePassword')}>
|
||||
<FormControl>
|
||||
<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>
|
||||
|
||||
<ViewColumn slot="side">
|
||||
@ -168,9 +170,11 @@
|
||||
<h3>{$t('account.avatar.title')}</h3>
|
||||
<AvatarCard src={`/api/avatar/${data.user.uuid}?t=${data.updateRef}`} alt={data.user.name}>
|
||||
<ViewColumn>
|
||||
<Button variant="primary" on:click={() => ($showAvatarModal = true)}
|
||||
>{$t('account.avatar.change')}</Button
|
||||
>
|
||||
<div>
|
||||
<Button variant="primary" on:click={() => ($showAvatarModal = true)}
|
||||
>{$t('account.avatar.change')}</Button
|
||||
>
|
||||
</div>
|
||||
{#if data.hasAvatar}
|
||||
<form action="?/removeAvatar" method="POST" use:enhance>
|
||||
<Button variant="link" type="submit">{$t('account.avatar.remove')}</Button>
|
||||
@ -189,9 +193,13 @@
|
||||
{/if}
|
||||
<a href="/account/two-factor">{$t('common.manage')}</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>{$t('account.authorizations.title')}</h3>
|
||||
<a href="/account/authorizations">{$t('common.manage')}</a>
|
||||
</div>
|
||||
</ViewColumn>
|
||||
</SplitView>
|
||||
<LogoutButton />
|
||||
</MainContainer>
|
||||
|
||||
<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 Alert from '$lib/components/Alert.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ColumnView from '$lib/components/ColumnView.svelte';
|
||||
import MainContainer from '$lib/components/MainContainer.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 FormControl from '$lib/components/form/FormControl.svelte';
|
||||
import FormSection from '$lib/components/form/FormSection.svelte';
|
||||
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||
@ -19,7 +20,11 @@
|
||||
</svelte:head>
|
||||
|
||||
<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>
|
||||
|
||||
<ColumnView>
|
||||
@ -50,17 +55,17 @@
|
||||
{:else if form?.invalid}
|
||||
<Alert type="error">{$t('account.errors.otpFailed')}</Alert>
|
||||
<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>
|
||||
{:else if data?.otpEnabled}
|
||||
<Alert type="success">{$t('account.otp.enabled')}</Alert>
|
||||
<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>
|
||||
{:else}
|
||||
<Alert type="default">{$t('account.otp.disabled')}</Alert>
|
||||
<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>
|
||||
{/if}
|
||||
</ColumnView>
|
||||
|
@ -16,8 +16,12 @@ interface LoginChallenge {
|
||||
email: string;
|
||||
}
|
||||
|
||||
const rainbowTableLimiter = new RateLimiter({
|
||||
IP: [3, '10s']
|
||||
});
|
||||
|
||||
const limiter = new RateLimiter({
|
||||
IP: [6, '45s']
|
||||
IP: [6, 'm']
|
||||
});
|
||||
|
||||
export const actions = {
|
||||
@ -54,6 +58,7 @@ export const actions = {
|
||||
// Find existing active user
|
||||
const loginUser = await Users.getByLogin(email);
|
||||
if (!loginUser) {
|
||||
if (await rainbowTableLimiter.isLimited(event)) throw error(429);
|
||||
return fail(400, { email, incorrect: true });
|
||||
}
|
||||
|
||||
@ -80,7 +85,8 @@ export const actions = {
|
||||
}
|
||||
} else {
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
|
@ -4,12 +4,12 @@
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import Alert from '$lib/components/Alert.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 FormSection from '$lib/components/form/FormSection.svelte';
|
||||
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||
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 form: ActionData;
|
||||
|
@ -4,9 +4,10 @@
|
||||
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
||||
import Alert from '$lib/components/Alert.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ColumnView from '$lib/components/ColumnView.svelte';
|
||||
import SideContainer from '$lib/components/SideContainer.svelte';
|
||||
import ColumnView from '$lib/components/container/ColumnView.svelte';
|
||||
import SideContainer from '$lib/components/container/SideContainer.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 { t } from '$lib/i18n';
|
||||
import type { ActionData, PageData, SubmitFunction } from './$types';
|
||||
@ -59,11 +60,7 @@
|
||||
{:else}
|
||||
<form action={actionUrl} method="POST" use:enhance={enhanceFn}>
|
||||
<FormWrapper>
|
||||
{#if errors.length}
|
||||
{#each errors as error}
|
||||
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
|
||||
{/each}
|
||||
{/if}
|
||||
<FormErrors {errors} prefix="account.errors" />
|
||||
|
||||
{#if data.setter}
|
||||
<FormControl>
|
||||
|
@ -3,8 +3,8 @@
|
||||
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
||||
import Alert from '$lib/components/Alert.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ColumnView from '$lib/components/ColumnView.svelte';
|
||||
import MainContainer from '$lib/components/MainContainer.svelte';
|
||||
import ColumnView from '$lib/components/container/ColumnView.svelte';
|
||||
import MainContainer from '$lib/components/container/MainContainer.svelte';
|
||||
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { PageData } from './$types';
|
||||
@ -46,6 +46,7 @@
|
||||
{/if}
|
||||
<div class="graphic" aria-hidden="true"></div>
|
||||
<div class="card">
|
||||
<!-- TODO: client pictures -->
|
||||
<AvatarCard src={`${assets}/application.png`} alt={data.client.title}>
|
||||
<div class="card-inner">
|
||||
<span class="card-display-name">{data.client.title}</span>
|
||||
|
@ -3,14 +3,15 @@
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
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 ColumnView from '$lib/components/ColumnView.svelte';
|
||||
import ColumnView from '$lib/components/container/ColumnView.svelte';
|
||||
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||
import FormControl from '$lib/components/form/FormControl.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import FormSection from '$lib/components/form/FormSection.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import FormErrors from '$lib/components/form/FormErrors.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
export let form: ActionData;
|
||||
@ -54,11 +55,7 @@
|
||||
{:else}
|
||||
<form action="" method="POST" use:enhance={enhanceFn}>
|
||||
<FormWrapper>
|
||||
{#if errors.length}
|
||||
{#each errors as error}
|
||||
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
|
||||
{/each}
|
||||
{/if}
|
||||
<FormErrors {errors} prefix="account.errors" />
|
||||
|
||||
<FormSection required>
|
||||
<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