Convert static environment to dynamic, theme toggle

This commit is contained in:
Evert Prants 2024-06-07 18:46:49 +03:00
parent 6febe18daa
commit adbc143926
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
43 changed files with 257 additions and 126 deletions

View File

@ -3,10 +3,7 @@ FROM node:20 AS builder
WORKDIR /usr/src/app
ARG envFile=.env
COPY . .
COPY ./${envFile} ./.env
RUN npm ci
RUN npm run build

View File

@ -1,4 +1,4 @@
import { AUTO_MIGRATE, SESSION_SECRET, SESSION_SECURE } from '$env/static/private';
import { env } from '$env/dynamic/private';
import { csrf } from '$lib/server/csrf';
import { DB } from '$lib/server/drizzle';
import { runSeeds } from '$lib/server/drizzle/seeds';
@ -7,6 +7,8 @@ import { sequence } from '@sveltejs/kit/hooks';
import { migrate } from 'drizzle-orm/mysql2/migrator';
import { handleSession } from 'svelte-kit-cookie-session';
const { AUTO_MIGRATE, SESSION_SECRET, SESSION_SECURE } = env;
await DB.init();
await JWT.init();

View File

@ -0,0 +1,12 @@
<script lang="ts">
import { setThemeMode, themeMode } from '$lib/theme-mode';
import Button from './Button.svelte';
import Icon from './icons/Icon.svelte';
const toggleMode = () => setThemeMode($themeMode === 'dark' ? 'light' : 'dark');
$: iconName = $themeMode === 'light' ? 'DarkMode' : 'LightMode';
</script>
<Button variant="link" on:click={toggleMode}>
<Icon icon={iconName} />
</Button>

View File

@ -1,14 +1,18 @@
<script lang="ts">
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import type { UserSession } from '$lib/types';
import ThemeButton from '../ThemeButton.svelte';
export let user: UserSession;
</script>
<header class="admin-header">
<a class="site-name" href="/">{PUBLIC_SITE_NAME}</a>
<div class="admin-user">
<img class="admin-user-avatar" src={`/api/avatar/${user.uuid}`} alt={user.name} />
<span class="admin-user-name">{user.name}</span>
<a class="site-name" href="/">{env.PUBLIC_SITE_NAME}</a>
<div class="aside">
<div class="admin-user">
<img class="admin-user-avatar" src={`/api/avatar/${user.uuid}`} alt={user.name} />
<span class="admin-user-name">{user.name}</span>
</div>
<ThemeButton />
</div>
</header>
@ -19,6 +23,15 @@
align-items: center;
padding: 8px 16px;
background: var(--ina-header-color);
& .aside {
display: flex;
gap: 1rem;
}
}
.admin-header .aside :global(button) {
color: var(--ina-header-link-color);
}
.admin-user {

View File

@ -9,8 +9,15 @@
align-items: center;
justify-content: space-between;
margin-bottom: 1.3rem;
@media screen and (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.title-row > :global(*):first-child {
align-self: flex-start;
margin: 0;
}
</style>

View File

@ -0,0 +1,24 @@
<script lang="ts">
export let icon: string;
$: iconComponent = import(`./svg/${icon}.svelte`);
</script>
<span class="icon">
{#await iconComponent then { default: component }}
<svelte:component this={component} />
{/await}
</span>
<style>
.icon {
width: 1rem;
height: 1rem;
display: inline-block;
vertical-align: middle;
}
.icon :global(svg) {
fill: currentColor;
}
</style>

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"
><path
d="M480-120q-150 0-255-105T120-480q0-150 105-255t255-105q14 0 27.5 1t26.5 3q-41 29-65.5 75.5T444-660q0 90 63 153t153 63q55 0 101-24.5t75-65.5q2 13 3 26.5t1 27.5q0 150-105 255T480-120Zm0-80q88 0 158-48.5T740-375q-20 5-40 8t-40 3q-123 0-209.5-86.5T364-660q0-20 3-40t8-40q-78 32-126.5 102T200-480q0 116 82 198t198 82Zm-10-270Z"
/></svg
>

After

Width:  |  Height:  |  Size: 411 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"
><path
d="M480-360q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Zm0 80q-83 0-141.5-58.5T280-480q0-83 58.5-141.5T480-680q83 0 141.5 58.5T680-480q0 83-58.5 141.5T480-280ZM200-440H40v-80h160v80Zm720 0H760v-80h160v80ZM440-760v-160h80v160h-80Zm0 720v-160h80v160h-80ZM256-650l-101-97 57-59 96 100-52 56Zm492 496-97-101 53-55 101 97-57 59Zm-98-550 97-101 59 57-100 96-56-52ZM154-212l101-97 55 53-97 101-59-57Zm326-268Z"
/></svg
>

After

Width:  |  Height:  |  Size: 517 B

View File

@ -26,6 +26,7 @@
"passwordSetSuccess": "Your new password has been set successfully! You may now log in.",
"passwordResetSucces": "If there is an account with that email address, we have sent a password reset email to it.",
"logout": "Log out",
"admin": "Admin",
"avatar": {
"title": "Profile avatar",
"change": "Change avatar",

View File

@ -1,4 +1,4 @@
import { CHALLENGE_SECRET } from '$env/static/private';
import { env } from '$env/dynamic/private';
import * as crypto from 'crypto';
import { v4 } from 'uuid';
@ -54,11 +54,11 @@ export class CryptoUtils {
}
public static async encryptChallenge<T>(challenge: T): Promise<string> {
return this.encrypt(JSON.stringify(challenge), CHALLENGE_SECRET);
return this.encrypt(JSON.stringify(challenge), env.CHALLENGE_SECRET);
}
public static async decryptChallenge<T>(challenge: string): Promise<T> {
return JSON.parse(this.decrypt(challenge, CHALLENGE_SECRET));
return JSON.parse(this.decrypt(challenge, env.CHALLENGE_SECRET));
}
static safeCompare(token: string, token2: string) {

View File

@ -1,8 +1,10 @@
import { DATABASE_DB, DATABASE_HOST, DATABASE_PASS } from '$env/static/private';
import { env } from '$env/dynamic/private';
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import * as schema from './schema';
const { DATABASE_DB, DATABASE_HOST, DATABASE_PASS } = env;
export class DB {
static mysqlConnection: mysql.Connection;
static drizzle: ReturnType<typeof drizzle<typeof schema>>;

View File

@ -1,4 +1,8 @@
import {
import { env } from '$env/dynamic/private';
import nodemailer from 'nodemailer';
import type { EmailTemplate } from './template.interface';
const {
EMAIL_ENABLED,
EMAIL_FROM,
EMAIL_SMTP_HOST,
@ -6,9 +10,7 @@ import {
EMAIL_SMTP_PORT,
EMAIL_SMTP_SECURE,
EMAIL_SMTP_USER
} from '$env/static/private';
import nodemailer from 'nodemailer';
import type { EmailTemplate } from './template.interface';
} = env;
export class Emails {
public transport?: nodemailer.Transporter;

View File

@ -1,25 +1,25 @@
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import type { EmailTemplate } from '../template.interface';
export const ForgotPasswordEmail = (username: string, url: string): EmailTemplate => ({
text: `
${PUBLIC_SITE_NAME}
${env.PUBLIC_SITE_NAME}
Hello, ${username}! You have requested a password reset on ${PUBLIC_SITE_NAME}.
Hello, ${username}! You have requested a password reset on ${env.PUBLIC_SITE_NAME}.
In order to change your password, please click on the following link.
Change your password: ${url}
If you did not request a password change on ${PUBLIC_SITE_NAME}, you can safely ignore this email.`,
If you did not request a password change on ${env.PUBLIC_SITE_NAME}, you can safely ignore this email.`,
html: /* html */ `
<h1>${PUBLIC_SITE_NAME}</h1>
<h1>${env.PUBLIC_SITE_NAME}</h1>
<p><strong>Hello, ${username}! You have requested a password reset on ${PUBLIC_SITE_NAME}.</strong></p>
<p><strong>Hello, ${username}! You have requested a password reset on ${env.PUBLIC_SITE_NAME}.</strong></p>
<p>In order to change your password, please click on the following link.</p>
<p>Change your password: <a href="${url}" target="_blank">${url}</a></p>
<p>If you did not request a password change on ${PUBLIC_SITE_NAME}, you can safely ignore this email.</p>`
<p>If you did not request a password change on ${env.PUBLIC_SITE_NAME}, you can safely ignore this email.</p>`
});

View File

@ -1,21 +1,21 @@
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import type { EmailTemplate } from '../template.interface';
export const InvitationEmail = (url: string): EmailTemplate => ({
text: `
${PUBLIC_SITE_NAME}
${env.PUBLIC_SITE_NAME}
Please click on the following link to create an account on ${PUBLIC_SITE_NAME}.
Please click on the following link to create an account on ${env.PUBLIC_SITE_NAME}.
Create your account here: ${url}
This email was sent to you because you have requested an account on ${PUBLIC_SITE_NAME}. If you did not request this, you may safely ignore this email.`,
This email was sent to you because you have requested an account on ${env.PUBLIC_SITE_NAME}. If you did not request this, you may safely ignore this email.`,
html: /* html */ `
<h1>${PUBLIC_SITE_NAME}</h1>
<h1>${env.PUBLIC_SITE_NAME}</h1>
<p><b>Please click on the following link to create an account on ${PUBLIC_SITE_NAME}.</b></p>
<p><b>Please click on the following link to create an account on ${env.PUBLIC_SITE_NAME}.</b></p>
<p>Create your account here: <a href="${url}" target="_blank">${url}</a></p>
<p>This email was sent to you because you have requested an account on ${PUBLIC_SITE_NAME}. If you did not request this, you may safely ignore this email.</p>`
<p>This email was sent to you because you have requested an account on ${env.PUBLIC_SITE_NAME}. If you did not request this, you may safely ignore this email.</p>`
});

View File

@ -1,4 +1,4 @@
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import type { EmailTemplate } from '../template.interface';
export const OAuth2InvitationEmail = (
@ -7,23 +7,23 @@ export const OAuth2InvitationEmail = (
url: string
): EmailTemplate => ({
text: `
${PUBLIC_SITE_NAME}
${env.PUBLIC_SITE_NAME}
${inviter} has invited you to edit the "${clientName}" application on ${PUBLIC_SITE_NAME}.
${inviter} has invited you to edit the "${clientName}" application on ${env.PUBLIC_SITE_NAME}.
Please use the following link to accept the invitation.
Accept invitation: ${url}
This email was sent to you because someone invited you to contribute to an application on ${PUBLIC_SITE_NAME}. If you believe that this was sent in error, you may safely ignore this email.`,
This email was sent to you because someone invited you to contribute to an application on ${env.PUBLIC_SITE_NAME}. If you believe that this was sent in error, you may safely ignore this email.`,
html: /* html */ `
<h1>${PUBLIC_SITE_NAME}</h1>
<h1>${env.PUBLIC_SITE_NAME}</h1>
<p>${inviter} has invited you to edit the "${clientName}" application on ${PUBLIC_SITE_NAME}.
<p>${inviter} has invited you to edit the "${clientName}" application on ${env.PUBLIC_SITE_NAME}.
<p><b>Please use the following link to accept the invitation:</b></p>
<p>Accept invitation: <a href="${url}" target="_blank">${url}</a></p>
<p>This email was sent to you because someone invited you to contribute to an application on ${PUBLIC_SITE_NAME}. If you believe that this was sent in error, you may safely ignore this email.</p>`
<p>This email was sent to you because someone invited you to contribute to an application on ${env.PUBLIC_SITE_NAME}. If you believe that this was sent in error, you may safely ignore this email.</p>`
});

View File

@ -1,25 +1,25 @@
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import type { EmailTemplate } from '../template.interface';
export const RegistrationEmail = (username: string, url: string): EmailTemplate => ({
text: `
${PUBLIC_SITE_NAME}
${env.PUBLIC_SITE_NAME}
Welcome to ${PUBLIC_SITE_NAME}, ${username}!
Welcome to ${env.PUBLIC_SITE_NAME}, ${username}!
In order to proceed with logging in, please click on the following link to activate your account.
Activate your account: ${url}
This email was sent to you because you have created an account on ${PUBLIC_SITE_NAME}. If you did not create an account, you may contact us or just let the account expire.`,
This email was sent to you because you have created an account on ${env.PUBLIC_SITE_NAME}. If you did not create an account, you may contact us or just let the account expire.`,
html: /* html */ `
<h1>${PUBLIC_SITE_NAME}</h1>
<h1>${env.PUBLIC_SITE_NAME}</h1>
<p><strong>Welcome to ${PUBLIC_SITE_NAME}, ${username}!</strong></p>
<p><strong>Welcome to ${env.PUBLIC_SITE_NAME}, ${username}!</strong></p>
<p>In order to proceed with logging in, please click on the following link to activate your account.</p>
<p>Activate your account: <a href="${url}" target="_blank">${url}</a></p>
<p>This email was sent to you because you have created an account on ${PUBLIC_SITE_NAME}. If you did not create an account, you may contact us or just let the account expire.</p>`
<p>This email was sent to you because you have created an account on ${env.PUBLIC_SITE_NAME}. If you did not create an account, you may contact us or just let the account expire.</p>`
});

View File

@ -1,4 +1,4 @@
import { JWT_ALGORITHM, JWT_EXPIRATION, JWT_ISSUER } from '$env/static/private';
import { env } from '$env/dynamic/private';
import { readFile } from 'fs/promises';
import {
SignJWT,
@ -12,6 +12,8 @@ import {
import { join } from 'path';
import { v4 as uuidv4 } from 'uuid';
const { JWT_ALGORITHM, JWT_EXPIRATION, JWT_ISSUER } = env;
/**
* Generate JWT keys using the following commands:
* Private: openssl genpkey -out jwt.private.pem -algorithm RSA -pkeyopt rsa_keygen_bits:2048

View File

@ -1,4 +1,4 @@
import { PUBLIC_URL, PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import { CryptoUtils } from '$lib/server/crypto-utils';
import {
DB,
@ -418,14 +418,14 @@ export class OAuth2Clients {
const content = OAuth2InvitationEmail(
actor.display_name,
client.title,
`${PUBLIC_URL}/account/accept-invite?${params.toString()}`
`${env.PUBLIC_URL}/account/accept-invite?${params.toString()}`
);
// TODO: logging
try {
await Emails.getSender().sendTemplate(
email,
`You have been invited to manage "${client.title}" on ${PUBLIC_SITE_NAME}`,
`You have been invited to manage "${client.title}" on ${env.PUBLIC_SITE_NAME}`,
content
);
} catch {

View File

@ -10,7 +10,7 @@ import { Users } from '$lib/server/users';
import { and, eq } from 'drizzle-orm';
import { OAuth2Clients } from './client';
import { OAuth2Tokens } from './tokens';
import { PUBLIC_URL } from '$env/static/public';
import { env } from '$env/dynamic/public';
import { JWT } from '$lib/server/jwt';
export class OAuth2Users {
@ -124,7 +124,7 @@ export class OAuth2Users {
}
if (scope.includes('picture') && subject.pictureId) {
userData.picture = `${PUBLIC_URL}/api/avatar/${subject.uuid}`;
userData.picture = `${env.PUBLIC_URL}/api/avatar/${subject.uuid}`;
}
return JWT.issue(userData, subject.uuid, client.client_id);

View File

@ -73,7 +73,7 @@ export class OAuth2Response {
return obj;
}
private static createResponse(code: number, data: unknown) {
static createResponse(code: number, data: unknown) {
const isJson = typeof data === 'object';
const body = isJson ? JSON.stringify(data) : (data as string);
return new Response(body, {
@ -84,7 +84,7 @@ export class OAuth2Response {
});
}
private static createErrorResponse(err: OAuth2Error) {
static createErrorResponse(err: OAuth2Error) {
return OAuth2Response.createResponse(err.status, {
error: err.code,
error_description: err.message

View File

@ -4,9 +4,9 @@ import { DB, privilege, user, userPrivilegesPrivilege, type User } from '../driz
import type { UserSession } from './types';
import { error, redirect } from '@sveltejs/kit';
import { CryptoUtils } from '../crypto-utils';
import { EMAIL_ENABLED } from '$env/static/private';
import { env as privateEnv } from '$env/dynamic/private';
import { Emails, ForgotPasswordEmail, InvitationEmail, RegistrationEmail } from '../email';
import { PUBLIC_SITE_NAME, PUBLIC_URL } from '$env/static/public';
import { env as publicEnv } from '$env/dynamic/public';
import { UserTokens } from './tokens';
export class Users {
@ -207,13 +207,13 @@ export class Users {
username,
password: passwordHash,
display_name: displayName,
activated: EMAIL_ENABLED === 'false' ? 1 : Number(activate),
activated: privateEnv.EMAIL_ENABLED === 'false' ? 1 : Number(activate),
activity_at: new Date()
});
const [newUser] = await DB.drizzle.select().from(user).where(eq(user.id, retval.insertId));
if (EMAIL_ENABLED !== 'false' && !activate) {
if (privateEnv.EMAIL_ENABLED !== 'false' && !activate) {
await Users.sendRegistrationEmail(newUser);
}
@ -234,13 +234,16 @@ export class Users {
);
const params = new URLSearchParams({ activate: token.token });
const content = RegistrationEmail(user.username, `${PUBLIC_URL}/login?${params.toString()}`);
const content = RegistrationEmail(
user.username,
`${publicEnv.PUBLIC_URL}/login?${params.toString()}`
);
// TODO: logging
try {
await Emails.getSender().sendTemplate(
user.email,
`Activate your account on ${PUBLIC_SITE_NAME}`,
`Activate your account on ${publicEnv.PUBLIC_SITE_NAME}`,
content
);
} catch (error) {
@ -262,14 +265,14 @@ export class Users {
const params = new URLSearchParams({ token: token.token });
const content = ForgotPasswordEmail(
user.username,
`${PUBLIC_URL}/login/password?${params.toString()}`
`${publicEnv.PUBLIC_URL}/login/password?${params.toString()}`
);
// TODO: logging
try {
await Emails.getSender().sendTemplate(
user.email,
`Reset your password on ${PUBLIC_SITE_NAME}`,
`Reset your password on ${publicEnv.PUBLIC_SITE_NAME}`,
content
);
} catch {
@ -290,13 +293,13 @@ export class Users {
`register=${email}`
);
const params = new URLSearchParams({ token: token.token });
const content = InvitationEmail(`${PUBLIC_URL}/register?${params.toString()}`);
const content = InvitationEmail(`${publicEnv.PUBLIC_URL}/register?${params.toString()}`);
// TODO: logging
try {
await Emails.getSender().sendTemplate(
email,
`You have been invited to create an account on ${PUBLIC_SITE_NAME}`,
`You have been invited to create an account on ${publicEnv.PUBLIC_SITE_NAME}`,
content
);
} catch {

View File

@ -1,7 +1,7 @@
import { authenticator as totp } from 'otplib';
import { DB, userToken, type User } from '../drizzle';
import { and, eq, gt, isNull, or } from 'drizzle-orm';
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
totp.options = {
window: 2

30
src/lib/theme-mode.ts Normal file
View File

@ -0,0 +1,30 @@
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
export type ThemeModeType = 'light' | 'dark';
export const themeMode = writable<ThemeModeType>('light', (set) => {
if (!browser) return;
const storageMode = window?.localStorage.getItem('inThemeMode') as ThemeModeType;
const uaTheme = window?.matchMedia?.('(prefers-color-scheme: dark)');
const uaMode: ThemeModeType = uaTheme?.matches ? 'dark' : 'light';
set(storageMode || uaMode || 'light');
const uaThemeCallback = () => set(uaTheme?.matches ? 'dark' : 'light');
uaTheme?.addEventListener('change', uaThemeCallback);
return () => uaTheme?.removeEventListener('change', uaThemeCallback);
});
export const useThemeMode = () => {
onMount(() =>
themeMode.subscribe((value) => document.documentElement.setAttribute('theme-base', value))
);
};
export const setThemeMode = (mode: ThemeModeType) => {
if (!browser) return;
themeMode.set(mode);
window.localStorage.setItem('inThemeMode', mode);
};

View File

@ -1,5 +1,8 @@
<script lang="ts">
import { useThemeMode } from '$lib/theme-mode';
import '../app.css';
useThemeMode();
</script>
<slot></slot>

View File

@ -1,8 +1,8 @@
import { JWT_ALGORITHM } from '$env/static/private';
import { env } from '$env/dynamic/private';
import { JWT } from '$lib/server/jwt';
import { json } from '@sveltejs/kit';
export const GET = async () =>
json({
keys: [{ alg: JWT_ALGORITHM, kid: JWT.jwksKid, ...JWT.jwks, use: 'sig' }]
keys: [{ alg: env.JWT_ALGORITHM, kid: JWT.jwksKid, ...JWT.jwks, use: 'sig' }]
});

View File

@ -1,17 +1,17 @@
import { JWT_ALGORITHM, JWT_ISSUER } from '$env/static/private';
import { PUBLIC_URL } from '$env/static/public';
import { env as privateEnv } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';
import { json } from '@sveltejs/kit';
export const GET = async () =>
json({
issuer: JWT_ISSUER,
authorization_endpoint: `${PUBLIC_URL}/oauth2/authorize`,
token_endpoint: `${PUBLIC_URL}/oauth2/token`,
jwks_uri: `${PUBLIC_URL}/.well-known/jwks.json`,
userinfo_endpoint: `${PUBLIC_URL}/api/user`,
introspection_endpoint: `${PUBLIC_URL}/oauth2/introspect`,
issuer: privateEnv.JWT_ISSUER,
authorization_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/authorize`,
token_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/token`,
jwks_uri: `${publicEnv.PUBLIC_URL}/.well-known/jwks.json`,
userinfo_endpoint: `${publicEnv.PUBLIC_URL}/api/user`,
introspection_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/introspect`,
response_types_supported: ['code', 'id_token'],
id_token_signing_alg_values_supported: [JWT_ALGORITHM],
id_token_signing_alg_values_supported: [privateEnv.JWT_ALGORITHM],
subject_types_supported: ['public'],
scopes_supported: ['openid', 'profile', 'picture', 'email'],
claims_supported: [

View File

@ -189,10 +189,12 @@ export async function load({ locals, url }) {
return redirect(301, `/login?redirectTo=${encodeURIComponent(url.pathname)}`);
}
const privileges = await Users.getUserPrivileges(currentUser);
const otpEnabled = await TimeOTP.isUserOtp(currentUser);
const updateRef = Date.now();
return {
privileges,
user: userInfo,
email: Users.anonymizeEmail(currentUser.email),
otpEnabled,

View File

@ -15,16 +15,20 @@
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 { env } from '$env/dynamic/public';
import FormErrors from '$lib/components/form/FormErrors.svelte';
import TitleRow from '$lib/components/container/TitleRow.svelte';
import ActionButton from '$lib/components/ActionButton.svelte';
import ButtonRow from '$lib/components/container/ButtonRow.svelte';
import ThemeButton from '$lib/components/ThemeButton.svelte';
import { hasPrivileges } from '$lib/utils';
export let data: PageData;
export let form: ActionData;
let internalErrors: string[] = [];
$: errors = [...internalErrors, ...(form?.errors?.length ? form.errors : [])];
$: adminButton = hasPrivileges(data.privileges, [['admin', 'self:oauth2']]);
let usernameRef: HTMLInputElement;
let displayRef: HTMLInputElement;
@ -56,14 +60,20 @@
</script>
<svelte:head>
<title>{$t('account.title')} - {PUBLIC_SITE_NAME}</title>
<title>{$t('account.title')} - {env.PUBLIC_SITE_NAME}</title>
</svelte:head>
<MainContainer>
<TitleRow>
<h1>{PUBLIC_SITE_NAME}</h1>
<h1>{env.PUBLIC_SITE_NAME}</h1>
<LogoutButton />
<ButtonRow>
<LogoutButton />
{#if adminButton}
<a href="/ssoadmin">{$t('account.admin')}</a>
{/if}
<ThemeButton />
</ButtonRow>
</TitleRow>
<SplitView>

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { assets } from '$app/paths';
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import Button from '$lib/components/Button.svelte';
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
import ColumnView from '$lib/components/container/ColumnView.svelte';
@ -14,12 +14,12 @@
</script>
<svelte:head>
<title>{$t('account.authorizations.title')} - {PUBLIC_SITE_NAME}</title>
<title>{$t('account.authorizations.title')} - {env.PUBLIC_SITE_NAME}</title>
</svelte:head>
<MainContainer>
<TitleRow>
<h1>{PUBLIC_SITE_NAME}</h1>
<h1>{env.PUBLIC_SITE_NAME}</h1>
<a href="/account">{$t('account.altTitle')}</a>
</TitleRow>

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte';
import ColumnView from '$lib/components/container/ColumnView.svelte';
@ -16,12 +16,12 @@
</script>
<svelte:head>
<title>{$t('account.otp.title')} - {PUBLIC_SITE_NAME}</title>
<title>{$t('account.otp.title')} - {env.PUBLIC_SITE_NAME}</title>
</svelte:head>
<MainContainer>
<TitleRow>
<h1>{PUBLIC_SITE_NAME}</h1>
<h1>{env.PUBLIC_SITE_NAME}</h1>
<a href="/account">{$t('account.altTitle')}</a>
</TitleRow>

View File

@ -1,4 +1,4 @@
import { PUBLIC_URL } from '$env/static/public';
import { env } from '$env/dynamic/public';
import type { User } from '$lib/server/drizzle/schema.js';
import { OAuth2BearerController } from '$lib/server/oauth2/controller/bearer.js';
import { AccessDenied, OAuth2Error } from '$lib/server/oauth2/error.js';
@ -57,7 +57,7 @@ export const GET = async ({ request, url, locals }) => {
}
if ((scopelessAccess || tokenScopes?.includes('picture')) && user.pictureId) {
userData.picture = `${PUBLIC_URL}/api/avatar/${user.uuid}`;
userData.picture = `${env.PUBLIC_URL}/api/avatar/${user.uuid}`;
}
if (scopelessAccess || tokenScopes?.includes('privileges')) {

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import Alert from '$lib/components/Alert.svelte';
@ -16,11 +16,11 @@
</script>
<svelte:head>
<title>{$t('account.login.title')} - {PUBLIC_SITE_NAME}</title>
<title>{$t('account.login.title')} - {env.PUBLIC_SITE_NAME}</title>
</svelte:head>
<SideContainer>
<h1>{PUBLIC_SITE_NAME}</h1>
<h1>{env.PUBLIC_SITE_NAME}</h1>
<h2>{$t('account.login.title')}</h2>
@ -71,7 +71,7 @@
</form>
<div class="welcome">
<p class="text-bold">{$t('common.description', { siteName: PUBLIC_SITE_NAME })}</p>
<p class="text-bold">{$t('common.description', { siteName: env.PUBLIC_SITE_NAME })}</p>
<p>{@html $t('common.cookieDisclaimer')}</p>
</div>
</SideContainer>

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { page } from '$app/stores';
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte';
import ButtonRow from '$lib/components/container/ButtonRow.svelte';
@ -44,11 +44,11 @@
</script>
<svelte:head>
<title>{$t(`account.${pageTitle}`)} - {PUBLIC_SITE_NAME}</title>
<title>{$t(`account.${pageTitle}`)} - {env.PUBLIC_SITE_NAME}</title>
</svelte:head>
<SideContainer>
<h1>{PUBLIC_SITE_NAME}</h1>
<h1>{env.PUBLIC_SITE_NAME}</h1>
<h2>{$t(`account.${pageTitle}`)}</h2>
<ColumnView>
{#if form?.success}

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { assets } from '$app/paths';
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte';
import ColumnView from '$lib/components/container/ColumnView.svelte';
@ -13,12 +13,13 @@
</script>
<svelte:head>
<title>{$t('oauth2.authorize.title')} "{data.client?.title || ''}" - {PUBLIC_SITE_NAME}</title>
<title>{$t('oauth2.authorize.title')} "{data.client?.title || ''}" - {env.PUBLIC_SITE_NAME}</title
>
</svelte:head>
<MainContainer>
{#if data.error}
<h1>{PUBLIC_SITE_NAME}</h1>
<h1>{env.PUBLIC_SITE_NAME}</h1>
<ColumnView>
<Alert type="error"
>{$t('oauth2.authorize.errorPage')}<br /><br /><code
@ -30,7 +31,7 @@
{/if}
{#if data.client}
<h1 class="title">{PUBLIC_SITE_NAME}</h1>
<h1 class="title">{env.PUBLIC_SITE_NAME}</h1>
<h2 class="title">{$t('oauth2.authorize.title')}</h2>
<div class="user-client-wrapper">

View File

@ -1,4 +1,4 @@
import { REGISTRATIONS } from '$env/static/private';
import { env } from '$env/dynamic/private';
import { Changesets } from '$lib/server/changesets.js';
import { Users } from '$lib/server/users/index.js';
import { emailRegex, passwordRegex, usernameRegex } from '$lib/validators.js';
@ -24,7 +24,7 @@ export const actions = {
if (await limiter.isLimited(event)) throw error(429);
// Logged in users cannot make more accounts
if (locals.session.data?.user || REGISTRATIONS === 'false') {
if (locals.session.data?.user || env.REGISTRATIONS === 'false') {
return redirect(303, '/');
}
@ -110,6 +110,6 @@ export const load = ({ locals }) => {
}
return {
enabled: REGISTRATIONS === 'true'
enabled: env.REGISTRATIONS === 'true'
};
};

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageData, ActionData } from './$types';
import { t } from '$lib/i18n';
@ -41,11 +41,11 @@
</script>
<svelte:head>
<title>{$t('account.register.title')} - {PUBLIC_SITE_NAME}</title>
<title>{$t('account.register.title')} - {env.PUBLIC_SITE_NAME}</title>
</svelte:head>
<SideContainer>
<h1>{PUBLIC_SITE_NAME}</h1>
<h1>{env.PUBLIC_SITE_NAME}</h1>
<h2>{$t('account.register.title')}</h2>
<ColumnView>

View File

@ -5,7 +5,7 @@
import ClientCard from '$lib/components/admin/AdminClientCard.svelte';
import TitleRow from '$lib/components/container/TitleRow.svelte';
import ColumnView from '$lib/components/container/ColumnView.svelte';
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import FormControl from '$lib/components/form/FormControl.svelte';
import { page } from '$app/stores';
@ -13,7 +13,7 @@
</script>
<svelte:head>
<title>{$t('admin.oauth2.title')} - {PUBLIC_SITE_NAME} {$t('admin.title')}</title>
<title>{$t('admin.oauth2.title')} - {env.PUBLIC_SITE_NAME} {$t('admin.title')}</title>
</svelte:head>
<TitleRow>

View File

@ -15,7 +15,7 @@
import { t } from '$lib/i18n';
import { page } from '$app/stores';
import { writable } from 'svelte/store';
import { PUBLIC_SITE_NAME, PUBLIC_URL } from '$env/static/public';
import { env } from '$env/dynamic/public';
import { OAUTH2_MAX_REDIRECTS, OAUTH2_MAX_URLS } from '$lib/constants';
export let data: PageData;
@ -43,7 +43,7 @@
<svelte:head>
<title
>{$t('admin.oauth2.title')} / {data.details.title} - {PUBLIC_SITE_NAME}
>{$t('admin.oauth2.title')} / {data.details.title} - {env.PUBLIC_SITE_NAME}
{$t('admin.title')}</title
>
</svelte:head>
@ -293,7 +293,7 @@
{#if data.fullPrivileges || data.details.isOwner}
<ColumnView>
<h2>{$t('admin.oauth2.managers.title')}</h2>
<p>{$t('admin.oauth2.managers.hint', { siteName: PUBLIC_SITE_NAME })}</p>
<p>{$t('admin.oauth2.managers.hint', { siteName: env.PUBLIC_SITE_NAME })}</p>
<div class="addremove">
{#each data.managers as user}
@ -343,7 +343,7 @@
{$t('admin.oauth2.apis.authorize')} -
<code
><a href={`/oauth2/authorize`} data-sveltekit-preload-data="off"
>{PUBLIC_URL}/oauth2/authorize</a
>{env.PUBLIC_URL}/oauth2/authorize</a
></code
>
</li>
@ -351,7 +351,7 @@
{$t('admin.oauth2.apis.token')} -
<code
><a href={`/oauth2/token`} data-sveltekit-preload-data="off"
>{PUBLIC_URL}/oauth2/token</a
>{env.PUBLIC_URL}/oauth2/token</a
></code
>
</li>
@ -359,21 +359,22 @@
{$t('admin.oauth2.apis.introspect')} -
<code
><a href={`/oauth2/introspect`} data-sveltekit-preload-data="off"
>{PUBLIC_URL}/oauth2/introspect</a
>{env.PUBLIC_URL}/oauth2/introspect</a
></code
>
</li>
<li>
{$t('admin.oauth2.apis.userinfo')} -
<code
><a href={`/api/user`} data-sveltekit-preload-data="off">{PUBLIC_URL}/api/user</a></code
><a href={`/api/user`} data-sveltekit-preload-data="off">{env.PUBLIC_URL}/api/user</a
></code
>
</li>
<li>
{$t('admin.oauth2.apis.openid')} -
<code
><a href={`/.well-known/openid-configuration`} data-sveltekit-preload-data="off"
>{PUBLIC_URL}/.well-known/openid-configuration</a
>{env.PUBLIC_URL}/.well-known/openid-configuration</a
></code
>
</li>

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import Button from '$lib/components/Button.svelte';
import AdminPrivilegesSelect from '$lib/components/admin/AdminPrivilegesSelect.svelte';
import ButtonRow from '$lib/components/container/ButtonRow.svelte';
@ -13,7 +13,7 @@
<svelte:head>
<title
>{$t('admin.oauth2.privileges.edit')} / {data.details.title} - {PUBLIC_SITE_NAME}
>{$t('admin.oauth2.privileges.edit')} / {data.details.title} - {env.PUBLIC_SITE_NAME}
{$t('admin.title')}</title
>
</svelte:head>

View File

@ -6,13 +6,13 @@
import Button from '$lib/components/Button.svelte';
import { t } from '$lib/i18n';
import type { ActionData } from './$types';
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
export let form: ActionData;
</script>
<svelte:head>
<title>{$t('admin.oauth2.new')} - {PUBLIC_SITE_NAME} {$t('admin.title')}</title>
<title>{$t('admin.oauth2.new')} - {env.PUBLIC_SITE_NAME} {$t('admin.title')}</title>
</svelte:head>
<h1>{$t('admin.oauth2.new')}</h1>

View File

@ -3,7 +3,7 @@
import { t } from '$lib/i18n';
import type { PageData } from './$types';
import UserCard from '$lib/components/admin/AdminUserCard.svelte';
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import FormControl from '$lib/components/form/FormControl.svelte';
import ColumnView from '$lib/components/container/ColumnView.svelte';
import { page } from '$app/stores';
@ -12,7 +12,7 @@
</script>
<svelte:head>
<title>{$t('admin.users.title')} - {PUBLIC_SITE_NAME} {$t('admin.title')}</title>
<title>{$t('admin.users.title')} - {env.PUBLIC_SITE_NAME} {$t('admin.title')}</title>
</svelte:head>
<h1>{$t('admin.users.title')}</h1>

View File

@ -96,7 +96,10 @@ export const actions = {
body
);
if (!!privileges && !hasPrivileges(userSession.privileges || [], ['admin:user:privilege'])) {
if (
privileges !== undefined &&
!hasPrivileges(userSession.privileges || [], ['admin:user:privilege'])
) {
return fail(403, { errors: ['unauthorized'] });
}
@ -109,9 +112,15 @@ export const actions = {
return fail(400, { errors: ['lockout'] });
}
if (privileges) {
// TODO: check NaNs
const newPrivilegeIds = privileges?.split(',').map(Number) || [];
if (privileges !== undefined) {
const newPrivilegeIds =
privileges?.split(',').reduce<number[]>((final, entry) => {
if (!entry) return final;
const parsed = Number(entry);
if (isNaN(parsed) || final.includes(parsed)) return final;
return [...final, parsed];
}, []) || [];
await Users.setUserPrivileges(targetUser, newPrivilegeIds);
}

View File

@ -10,7 +10,7 @@
import type { ActionData, PageData } from './$types';
import AdminPrivilegesSelect from '$lib/components/admin/AdminPrivilegesSelect.svelte';
import FormErrors from '$lib/components/form/FormErrors.svelte';
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { env } from '$env/dynamic/public';
import ActionButton from '$lib/components/ActionButton.svelte';
import Alert from '$lib/components/Alert.svelte';
@ -20,7 +20,7 @@
<svelte:head>
<title
>{$t('admin.users.title')} / {data.details.display_name} - {PUBLIC_SITE_NAME}
>{$t('admin.users.title')} / {data.details.display_name} - {env.PUBLIC_SITE_NAME}
{$t('admin.title')}</title
>
</svelte:head>