diff --git a/Dockerfile b/Dockerfile index 8c2ef63..cdf87db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 4448d61..dfb9f05 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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(); diff --git a/src/lib/components/ThemeButton.svelte b/src/lib/components/ThemeButton.svelte new file mode 100644 index 0000000..1a24c8b --- /dev/null +++ b/src/lib/components/ThemeButton.svelte @@ -0,0 +1,12 @@ + + + diff --git a/src/lib/components/admin/AdminHeader.svelte b/src/lib/components/admin/AdminHeader.svelte index 69c9123..38142cd 100644 --- a/src/lib/components/admin/AdminHeader.svelte +++ b/src/lib/components/admin/AdminHeader.svelte @@ -1,14 +1,18 @@
- {PUBLIC_SITE_NAME} -
- {user.name} - {user.name} + {env.PUBLIC_SITE_NAME} +
+
+ {user.name} + {user.name} +
+
@@ -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 { diff --git a/src/lib/components/container/TitleRow.svelte b/src/lib/components/container/TitleRow.svelte index baa191f..8d8d1fa 100644 --- a/src/lib/components/container/TitleRow.svelte +++ b/src/lib/components/container/TitleRow.svelte @@ -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; } diff --git a/src/lib/components/icons/Icon.svelte b/src/lib/components/icons/Icon.svelte new file mode 100644 index 0000000..455fe57 --- /dev/null +++ b/src/lib/components/icons/Icon.svelte @@ -0,0 +1,24 @@ + + + + {#await iconComponent then { default: component }} + + {/await} + + + diff --git a/src/lib/components/icons/svg/DarkMode.svelte b/src/lib/components/icons/svg/DarkMode.svelte new file mode 100644 index 0000000..c94e96d --- /dev/null +++ b/src/lib/components/icons/svg/DarkMode.svelte @@ -0,0 +1,5 @@ + diff --git a/src/lib/components/icons/svg/LightMode.svelte b/src/lib/components/icons/svg/LightMode.svelte new file mode 100644 index 0000000..0a163ef --- /dev/null +++ b/src/lib/components/icons/svg/LightMode.svelte @@ -0,0 +1,5 @@ + diff --git a/src/lib/i18n/en/account.json b/src/lib/i18n/en/account.json index 7876a6a..dedaf67 100644 --- a/src/lib/i18n/en/account.json +++ b/src/lib/i18n/en/account.json @@ -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", diff --git a/src/lib/server/crypto-utils.ts b/src/lib/server/crypto-utils.ts index 31d1786..b6d9317 100644 --- a/src/lib/server/crypto-utils.ts +++ b/src/lib/server/crypto-utils.ts @@ -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(challenge: T): Promise { - return this.encrypt(JSON.stringify(challenge), CHALLENGE_SECRET); + return this.encrypt(JSON.stringify(challenge), env.CHALLENGE_SECRET); } public static async decryptChallenge(challenge: string): Promise { - return JSON.parse(this.decrypt(challenge, CHALLENGE_SECRET)); + return JSON.parse(this.decrypt(challenge, env.CHALLENGE_SECRET)); } static safeCompare(token: string, token2: string) { diff --git a/src/lib/server/drizzle/index.ts b/src/lib/server/drizzle/index.ts index a5fa111..0c8933f 100644 --- a/src/lib/server/drizzle/index.ts +++ b/src/lib/server/drizzle/index.ts @@ -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>; diff --git a/src/lib/server/email/index.ts b/src/lib/server/email/index.ts index 1a6361e..d67c913 100644 --- a/src/lib/server/email/index.ts +++ b/src/lib/server/email/index.ts @@ -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; diff --git a/src/lib/server/email/templates/forgot-password.email.ts b/src/lib/server/email/templates/forgot-password.email.ts index f64280a..00fcf10 100644 --- a/src/lib/server/email/templates/forgot-password.email.ts +++ b/src/lib/server/email/templates/forgot-password.email.ts @@ -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 */ ` -

${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.

` }); diff --git a/src/lib/server/email/templates/invitation.email.ts b/src/lib/server/email/templates/invitation.email.ts index 61ec75c..958b0d4 100644 --- a/src/lib/server/email/templates/invitation.email.ts +++ b/src/lib/server/email/templates/invitation.email.ts @@ -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 */ ` -

${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.

` }); diff --git a/src/lib/server/email/templates/oauth2-invitation.email.ts b/src/lib/server/email/templates/oauth2-invitation.email.ts index 4514054..de58bb4 100644 --- a/src/lib/server/email/templates/oauth2-invitation.email.ts +++ b/src/lib/server/email/templates/oauth2-invitation.email.ts @@ -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 */ ` -

${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.

` }); diff --git a/src/lib/server/email/templates/registration.email.ts b/src/lib/server/email/templates/registration.email.ts index 23f5b56..673d9e2 100644 --- a/src/lib/server/email/templates/registration.email.ts +++ b/src/lib/server/email/templates/registration.email.ts @@ -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 */ ` -

${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.

` }); diff --git a/src/lib/server/jwt.ts b/src/lib/server/jwt.ts index 9b209fb..9da7123 100644 --- a/src/lib/server/jwt.ts +++ b/src/lib/server/jwt.ts @@ -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 diff --git a/src/lib/server/oauth2/model/client.ts b/src/lib/server/oauth2/model/client.ts index 43cba96..c7f107e 100644 --- a/src/lib/server/oauth2/model/client.ts +++ b/src/lib/server/oauth2/model/client.ts @@ -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 { diff --git a/src/lib/server/oauth2/model/user.ts b/src/lib/server/oauth2/model/user.ts index 2aa7cc9..431c684 100644 --- a/src/lib/server/oauth2/model/user.ts +++ b/src/lib/server/oauth2/model/user.ts @@ -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); diff --git a/src/lib/server/oauth2/response.ts b/src/lib/server/oauth2/response.ts index 73b2c34..2f8e8f6 100644 --- a/src/lib/server/oauth2/response.ts +++ b/src/lib/server/oauth2/response.ts @@ -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 diff --git a/src/lib/server/users/index.ts b/src/lib/server/users/index.ts index 3f182d2..5d31c52 100644 --- a/src/lib/server/users/index.ts +++ b/src/lib/server/users/index.ts @@ -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 { diff --git a/src/lib/server/users/totp.ts b/src/lib/server/users/totp.ts index bac21f3..720b0a7 100644 --- a/src/lib/server/users/totp.ts +++ b/src/lib/server/users/totp.ts @@ -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 diff --git a/src/lib/theme-mode.ts b/src/lib/theme-mode.ts new file mode 100644 index 0000000..002d154 --- /dev/null +++ b/src/lib/theme-mode.ts @@ -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('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); +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c3bace0..ea152e2 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,5 +1,8 @@ diff --git a/src/routes/[...wellKnown=wellKnown]/jwks.json/+server.ts b/src/routes/[...wellKnown=wellKnown]/jwks.json/+server.ts index 0a7d15c..c5d6841 100644 --- a/src/routes/[...wellKnown=wellKnown]/jwks.json/+server.ts +++ b/src/routes/[...wellKnown=wellKnown]/jwks.json/+server.ts @@ -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' }] }); diff --git a/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts b/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts index 11511a3..94e858f 100644 --- a/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts +++ b/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts @@ -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: [ diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts index 82ae4f4..8e1bf77 100644 --- a/src/routes/account/+page.server.ts +++ b/src/routes/account/+page.server.ts @@ -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, diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index 23dcb0b..6cbe98f 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -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 @@ - {$t('account.title')} - {PUBLIC_SITE_NAME} + {$t('account.title')} - {env.PUBLIC_SITE_NAME} -

{PUBLIC_SITE_NAME}

+

{env.PUBLIC_SITE_NAME}

- + + + {#if adminButton} + {$t('account.admin')} + {/if} + +
diff --git a/src/routes/account/authorizations/+page.svelte b/src/routes/account/authorizations/+page.svelte index bcf2052..0cbd678 100644 --- a/src/routes/account/authorizations/+page.svelte +++ b/src/routes/account/authorizations/+page.svelte @@ -1,7 +1,7 @@ - {$t('account.authorizations.title')} - {PUBLIC_SITE_NAME} + {$t('account.authorizations.title')} - {env.PUBLIC_SITE_NAME} -

{PUBLIC_SITE_NAME}

+

{env.PUBLIC_SITE_NAME}

{$t('account.altTitle')}
diff --git a/src/routes/account/two-factor/+page.svelte b/src/routes/account/two-factor/+page.svelte index bccb449..9f3c777 100644 --- a/src/routes/account/two-factor/+page.svelte +++ b/src/routes/account/two-factor/+page.svelte @@ -1,5 +1,5 @@ - {$t('account.otp.title')} - {PUBLIC_SITE_NAME} + {$t('account.otp.title')} - {env.PUBLIC_SITE_NAME} -

{PUBLIC_SITE_NAME}

+

{env.PUBLIC_SITE_NAME}

{$t('account.altTitle')}
diff --git a/src/routes/api/user/+server.ts b/src/routes/api/user/+server.ts index 0c1c4fa..a1d0204 100644 --- a/src/routes/api/user/+server.ts +++ b/src/routes/api/user/+server.ts @@ -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')) { diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index e1dd0d8..a7cb31c 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -1,5 +1,5 @@ - {$t('account.login.title')} - {PUBLIC_SITE_NAME} + {$t('account.login.title')} - {env.PUBLIC_SITE_NAME} -

{PUBLIC_SITE_NAME}

+

{env.PUBLIC_SITE_NAME}

{$t('account.login.title')}

@@ -71,7 +71,7 @@
-

{$t('common.description', { siteName: PUBLIC_SITE_NAME })}

+

{$t('common.description', { siteName: env.PUBLIC_SITE_NAME })}

{@html $t('common.cookieDisclaimer')}

diff --git a/src/routes/login/password/+page.svelte b/src/routes/login/password/+page.svelte index 279f57c..66b806f 100644 --- a/src/routes/login/password/+page.svelte +++ b/src/routes/login/password/+page.svelte @@ -1,7 +1,7 @@ - {$t(`account.${pageTitle}`)} - {PUBLIC_SITE_NAME} + {$t(`account.${pageTitle}`)} - {env.PUBLIC_SITE_NAME} -

{PUBLIC_SITE_NAME}

+

{env.PUBLIC_SITE_NAME}

{$t(`account.${pageTitle}`)}

{#if form?.success} diff --git a/src/routes/oauth2/authorize/+page.svelte b/src/routes/oauth2/authorize/+page.svelte index 4a641a0..a7672d5 100644 --- a/src/routes/oauth2/authorize/+page.svelte +++ b/src/routes/oauth2/authorize/+page.svelte @@ -1,6 +1,6 @@ - {$t('oauth2.authorize.title')} "{data.client?.title || ''}" - {PUBLIC_SITE_NAME} + {$t('oauth2.authorize.title')} "{data.client?.title || ''}" - {env.PUBLIC_SITE_NAME} {#if data.error} -

{PUBLIC_SITE_NAME}

+

{env.PUBLIC_SITE_NAME}

{$t('oauth2.authorize.errorPage')}

{PUBLIC_SITE_NAME} +

{env.PUBLIC_SITE_NAME}

{$t('oauth2.authorize.title')}

diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts index 10ac81e..ea0f189 100644 --- a/src/routes/register/+page.server.ts +++ b/src/routes/register/+page.server.ts @@ -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' }; }; diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte index 69d37ad..ca3668e 100644 --- a/src/routes/register/+page.svelte +++ b/src/routes/register/+page.svelte @@ -1,5 +1,5 @@ - {$t('account.register.title')} - {PUBLIC_SITE_NAME} + {$t('account.register.title')} - {env.PUBLIC_SITE_NAME} -

{PUBLIC_SITE_NAME}

+

{env.PUBLIC_SITE_NAME}

{$t('account.register.title')}

diff --git a/src/routes/ssoadmin/oauth2/+page.svelte b/src/routes/ssoadmin/oauth2/+page.svelte index 177baa0..6a18364 100644 --- a/src/routes/ssoadmin/oauth2/+page.svelte +++ b/src/routes/ssoadmin/oauth2/+page.svelte @@ -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 @@ - {$t('admin.oauth2.title')} - {PUBLIC_SITE_NAME} {$t('admin.title')} + {$t('admin.oauth2.title')} - {env.PUBLIC_SITE_NAME} {$t('admin.title')} diff --git a/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte b/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte index 6e2977c..3f37815 100644 --- a/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte +++ b/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte @@ -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 @@ {$t('admin.oauth2.title')} / {data.details.title} - {PUBLIC_SITE_NAME} + >{$t('admin.oauth2.title')} / {data.details.title} - {env.PUBLIC_SITE_NAME} {$t('admin.title')} @@ -293,7 +293,7 @@ {#if data.fullPrivileges || data.details.isOwner}

{$t('admin.oauth2.managers.title')}

-

{$t('admin.oauth2.managers.hint', { siteName: PUBLIC_SITE_NAME })}

+

{$t('admin.oauth2.managers.hint', { siteName: env.PUBLIC_SITE_NAME })}

{#each data.managers as user} @@ -343,7 +343,7 @@ {$t('admin.oauth2.apis.authorize')} - {PUBLIC_URL}/oauth2/authorize{env.PUBLIC_URL}/oauth2/authorize @@ -351,7 +351,7 @@ {$t('admin.oauth2.apis.token')} - {PUBLIC_URL}/oauth2/token{env.PUBLIC_URL}/oauth2/token @@ -359,21 +359,22 @@ {$t('admin.oauth2.apis.introspect')} - {PUBLIC_URL}/oauth2/introspect{env.PUBLIC_URL}/oauth2/introspect
  • {$t('admin.oauth2.apis.userinfo')} - {PUBLIC_URL}/api/user{env.PUBLIC_URL}/api/user
  • {$t('admin.oauth2.apis.openid')} - {PUBLIC_URL}/.well-known/openid-configuration{env.PUBLIC_URL}/.well-known/openid-configuration
  • diff --git a/src/routes/ssoadmin/oauth2/[uuid]/user/[user]/+page.svelte b/src/routes/ssoadmin/oauth2/[uuid]/user/[user]/+page.svelte index 933cd38..44c42fb 100644 --- a/src/routes/ssoadmin/oauth2/[uuid]/user/[user]/+page.svelte +++ b/src/routes/ssoadmin/oauth2/[uuid]/user/[user]/+page.svelte @@ -1,5 +1,5 @@ - {$t('admin.oauth2.new')} - {PUBLIC_SITE_NAME} {$t('admin.title')} + {$t('admin.oauth2.new')} - {env.PUBLIC_SITE_NAME} {$t('admin.title')}

    {$t('admin.oauth2.new')}

    diff --git a/src/routes/ssoadmin/users/+page.svelte b/src/routes/ssoadmin/users/+page.svelte index 85ec557..27920a4 100644 --- a/src/routes/ssoadmin/users/+page.svelte +++ b/src/routes/ssoadmin/users/+page.svelte @@ -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 @@ - {$t('admin.users.title')} - {PUBLIC_SITE_NAME} {$t('admin.title')} + {$t('admin.users.title')} - {env.PUBLIC_SITE_NAME} {$t('admin.title')}

    {$t('admin.users.title')}

    diff --git a/src/routes/ssoadmin/users/[uuid]/+page.server.ts b/src/routes/ssoadmin/users/[uuid]/+page.server.ts index 09097c3..58cafe2 100644 --- a/src/routes/ssoadmin/users/[uuid]/+page.server.ts +++ b/src/routes/ssoadmin/users/[uuid]/+page.server.ts @@ -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((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); } diff --git a/src/routes/ssoadmin/users/[uuid]/+page.svelte b/src/routes/ssoadmin/users/[uuid]/+page.svelte index a8ef680..09a0be9 100644 --- a/src/routes/ssoadmin/users/[uuid]/+page.svelte +++ b/src/routes/ssoadmin/users/[uuid]/+page.svelte @@ -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 @@ {$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')}