1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-11 06:34:19 +00:00

feat: add support for translations (#349)

Co-authored-by: Kyle Mendell <kmendell@outlook.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Jonas Claes
2025-03-20 19:57:41 +01:00
committed by GitHub
parent 041c565dc1
commit 269b5a3c92
83 changed files with 1567 additions and 453 deletions

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="%lang%">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/api/application-configuration/favicon" />

View File

@@ -1,6 +1,8 @@
import { env } from '$env/dynamic/private';
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import { paraglideMiddleware } from '$lib/paraglide/server';
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { AxiosError } from 'axios';
import { decodeJwt } from 'jose';
@@ -9,7 +11,16 @@ import { decodeJwt } from 'jose';
// this is still secure as process will just be undefined in the browser
process.env.INTERNAL_BACKEND_URL = env.INTERNAL_BACKEND_URL ?? 'http://localhost:8080';
export const handle: Handle = async ({ event, resolve }) => {
// Handle to use the paraglide middleware
const paraglideHandle: Handle = ({ event, resolve }) => {
return paraglideMiddleware(event.request, ({ locale }) => {
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%lang%', locale)
});
});
};
const authenticationHandle: Handle = async ({ event, resolve }) => {
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/lc')
@@ -43,6 +54,8 @@ export const handle: Handle = async ({ event, resolve }) => {
return response;
};
export const handle: Handle = sequence(paraglideHandle, authenticationHandle);
export const handleError: HandleServerError = async ({ error, message, status }) => {
if (error instanceof AxiosError) {
message = error.response?.data.error || message;

View File

@@ -11,6 +11,7 @@
import { ChevronDown } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import Button from './ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
let {
items,
@@ -93,7 +94,7 @@
'relative z-50 mb-4 max-w-sm',
items.data.length == 0 && searchValue == '' && 'hidden'
)}
placeholder={'Search...'}
placeholder={m.search()}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/>
@@ -102,7 +103,7 @@
{#if items.data.length === 0 && searchValue === ''}
<div class="my-5 flex flex-col items-center">
<Empty class="text-muted-foreground h-20" />
<p class="text-muted-foreground mt-3 text-sm">No items found</p>
<p class="text-muted-foreground mt-3 text-sm">{m.no_items_found()}</p>
</div>
{:else}
<Table.Root class="min-w-full table-auto overflow-x-auto">
@@ -166,7 +167,7 @@
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Items per page</p>
<p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root
selected={{
label: items.pagination.itemsPerPage.toString(),

View File

@@ -5,6 +5,7 @@
import { slide } from 'svelte/transition';
import { Button } from './ui/button';
import * as Card from './ui/card';
import { m } from '$lib/paraglide/messages';
let {
id,
@@ -55,7 +56,7 @@
<Card.Description>{description}</Card.Description>
{/if}
</div>
<Button class="ml-10 h-8 p-3" variant="ghost" aria-label="Expand card">
<Button class="ml-10 h-8 p-3" variant="ghost" aria-label={m.expand_card()}>
<LucideChevronDown
class={cn(
'h-5 w-5 transition-transform duration-200',

View File

@@ -1,12 +1,13 @@
import { writable } from 'svelte/store';
import ConfirmDialog from './confirm-dialog.svelte';
import { m } from '$lib/paraglide/messages';
export const confirmDialogStore = writable({
open: false,
title: '',
message: '',
confirm: {
label: 'Confirm',
label: m.confirm(),
destructive: false,
action: () => {}
}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages';
import { LucideCheck } from 'lucide-svelte';
import type { Snippet } from 'svelte';
@@ -31,9 +32,9 @@
<Tooltip.Trigger class="text-start" tabindex={-1} onclick={onClick}>{@render children()}</Tooltip.Trigger>
<Tooltip.Content onclick={copyToClipboard}>
{#if copied}
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span>
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> {m.copied()}</span>
{:else}
<span>Click to copy</span>
<span>{m.click_to_copy()}</span>
{/if}
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import { LucideXCircle } from 'lucide-svelte';
let { message, showButton = true }: { message: string; showButton?: boolean } = $props();
@@ -7,9 +8,9 @@
<div class="mt-[20%] flex flex-col items-center">
<LucideXCircle class="h-12 w-12 text-muted-foreground" />
<h1 class="mt-3 text-2xl font-semibold">Something went wrong</h1>
<h1 class="mt-3 text-2xl font-semibold">{m.something_went_wrong()}</h1>
<p class="text-muted-foreground">{message}</p>
{#if showButton}
<Button size="sm" class="mt-5" href="/">Go back to home</Button>
<Button size="sm" class="mt-5" href="/">{m.go_back_to_home()}</Button>
{/if}
</div>

View File

@@ -8,6 +8,7 @@
import { onMount, type Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import AutoCompleteInput from './auto-complete-input.svelte';
import { m } from '$lib/paraglide/messages';
let {
customClaims = $bindable(),
@@ -41,15 +42,15 @@
{#each customClaims as _, i}
<div class="flex gap-x-2">
<AutoCompleteInput
placeholder="Key"
placeholder={m.key()}
suggestions={filteredSuggestions}
bind:value={customClaims[i].key}
/>
<Input placeholder="Value" bind:value={customClaims[i].value} />
<Input placeholder={m.value()} bind:value={customClaims[i].value} />
<Button
variant="outline"
size="sm"
aria-label="Remove custom claim"
aria-label={m.remove_custom_claim()}
on:click={() => (customClaims = customClaims.filter((_, index) => index !== i))}
>
<LucideMinus class="h-4 w-4" />
@@ -69,7 +70,7 @@
on:click={() => (customClaims = [...customClaims, { key: '', value: '' }])}
>
<LucidePlus class="mr-1 h-4 w-4" />
{customClaims.length === 0 ? 'Add custom claim' : 'Add another'}
{customClaims.length === 0 ? m.add_custom_claim() : m.add_another()}
</Button>
{/if}
</div>

View File

@@ -2,6 +2,8 @@
import { Button } from '$lib/components/ui/button';
import { Calendar } from '$lib/components/ui/calendar';
import * as Popover from '$lib/components/ui/popover';
import { m } from '$lib/paraglide/messages';
import { getLocale } from '$lib/paraglide/runtime';
import { cn } from '$lib/utils/style';
import {
CalendarDate,
@@ -30,7 +32,7 @@
open = false;
}
const df = new DateFormatter('en-US', {
const df = new DateFormatter(getLocale(), {
dateStyle: 'long'
});
</script>
@@ -44,7 +46,7 @@
builders={[builder]}
>
<CalendarIcon class="mr-2 h-4 w-4" />
{date ? df.format(date.toDate(getLocalTimeZone())) : 'Select a date'}
{date ? df.format(date.toDate(getLocalTimeZone())) : m.select_a_date()}
</Button>
</Popover.Trigger>
<Popover.Content class="w-auto p-0" align="start">

View File

@@ -3,6 +3,7 @@
import type { HTMLInputAttributes } from 'svelte/elements';
import type { VariantProps } from 'tailwind-variants';
import type { buttonVariants } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
let {
id,
@@ -21,7 +22,7 @@
{#if restProps.children}
{@render restProps.children()}
{:else}
Select File
{m.select_file()}
{/if}
</button>
<input {id} {...restProps} type="file" class="hidden" />

View File

@@ -4,6 +4,7 @@
import Button from '$lib/components/ui/button/button.svelte';
import { LucideLoader, LucideRefreshCw, LucideUpload } from 'lucide-svelte';
import { openConfirmDialog } from '../confirm-dialog';
import { m } from '$lib/paraglide/messages';
let {
userId,
@@ -40,11 +41,10 @@
function onReset() {
openConfirmDialog({
title: 'Reset profile picture?',
message:
'This will remove the uploaded image, and reset the profile picture to default. Do you want to continue?',
title: m.reset_profile_picture_question(),
message: m.this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default(),
confirm: {
label: 'Reset',
label: m.reset(),
action: async () => {
isLoading = true;
await resetCallback().catch();
@@ -58,16 +58,16 @@
<div class="flex gap-5">
<div class="flex w-full flex-col justify-between gap-5 sm:flex-row">
<div>
<h3 class="text-xl font-semibold">Profile Picture</h3>
<h3 class="text-xl font-semibold">{m.profile_picture()}</h3>
{#if isLdapUser}
<p class="text-muted-foreground mt-1 text-sm">
The profile picture is managed by the LDAP server and cannot be changed here.
{m.profile_picture_is_managed_by_ldap_server()}
</p>
{:else}
<p class="text-muted-foreground mt-1 text-sm">
Click on the profile picture to upload a custom one from your files.
{m.click_profile_picture_to_upload_custom()}
</p>
<p class="text-muted-foreground mt-1 text-sm">The image should be in PNG or JPEG format.</p>
<p class="text-muted-foreground mt-1 text-sm">{m.image_should_be_in_format()}</p>
{/if}
<Button
variant="outline"
@@ -77,7 +77,7 @@
disabled={isLoading || isLdapUser}
>
<LucideRefreshCw class="mr-2 h-4 w-4" />
Reset to default
{m.reset_to_default()}
</Button>
</div>
{#if isLdapUser}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import * as Avatar from '$lib/components/ui/avatar';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store';
import { LucideLogOut, LucideUser } from 'lucide-svelte';
@@ -32,10 +33,10 @@
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item href="/settings/account"
><LucideUser class="mr-2 h-4 w-4" /> My Account</DropdownMenu.Item
><LucideUser class="mr-2 h-4 w-4" /> {m.my_account()}</DropdownMenu.Item
>
<DropdownMenu.Item on:click={logout}
><LucideLogOut class="mr-2 h-4 w-4" /> Logout</DropdownMenu.Item
><LucideLogOut class="mr-2 h-4 w-4" /> {m.logout()}</DropdownMenu.Item
>
</DropdownMenu.Group>
</DropdownMenu.Content>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import Logo from '../logo.svelte';
@@ -8,7 +8,7 @@
const authUrls = [/^\/authorize$/, /^\/login(?:\/.*)?$/, /^\/logout$/];
let isAuthPage = $derived(
!$page.error && authUrls.some((pattern) => pattern.test($page.url.pathname))
!page.error && authUrls.some((pattern) => pattern.test(page.url.pathname))
);
</script>
@@ -26,8 +26,10 @@
</h1>
{/if}
</div>
{#if $userStore?.id}
<HeaderAvatar />
{/if}
<div class="flex items-center justify-between gap-4">
{#if $userStore?.id}
<HeaderAvatar />
{/if}
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { page } from '$app/state';
import type { Snippet } from 'svelte';
import * as Card from './ui/card';
import { m } from '$lib/paraglide/messages';
let {
children,
@@ -29,7 +30,7 @@
)}`}
class="text-muted-foreground text-xs"
>
Don't have access to your passkey?
{m.dont_have_access_to_your_passkey()}
</a>
</div>
{/if}
@@ -38,7 +39,7 @@
<img
src="/api/application-configuration/background-image"
class="h-screen w-[calc(100vw-650px)] rounded-l-[60px] object-cover"
alt="Login background"
alt={m.login_background()}
/>
</div>
@@ -60,7 +61,7 @@
)}`}
class="text-muted-foreground mt-7 flex justify-center text-xs"
>
Don't have access to your passkey?
{m.dont_have_access_to_your_passkey()}
</a>
{/if}
</Card.CardContent>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { mode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements';
@@ -7,4 +8,4 @@
const isDarkMode = $derived($mode === 'dark');
</script>
<img {...props} src="/api/application-configuration/logo?light={!isDarkMode}" alt="Logo" />
<img {...props} src="/api/application-configuration/logo?light={!isDarkMode}" alt={m.logo()} />

View File

@@ -5,6 +5,7 @@
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select/index.js';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -17,14 +18,14 @@
const userService = new UserService();
let oneTimeLink: string | null = $state(null);
let selectedExpiration: keyof typeof availableExpirations = $state('1 hour');
let selectedExpiration: keyof typeof availableExpirations = $state(m.one_hour());
let availableExpirations = {
'1 hour': 60 * 60,
'12 hours': 60 * 60 * 12,
'1 day': 60 * 60 * 24,
'1 week': 60 * 60 * 24 * 7,
'1 month': 60 * 60 * 24 * 30
[m.one_hour()]: 60 * 60,
[m.twelve_hours()]: 60 * 60 * 12,
[m.one_day()]: 60 * 60 * 24,
[m.one_week()]: 60 * 60 * 24 * 7,
[m.one_month()]: 60 * 60 * 24 * 30
};
async function createOneTimeAccessToken() {
@@ -48,14 +49,14 @@
<Dialog.Root open={!!userId} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Login Code</Dialog.Title>
<Dialog.Title>{m.login_code()}</Dialog.Title>
<Dialog.Description
>Create a login code that the user can use to sign in without a passkey once.</Dialog.Description
>{m.create_a_login_code_to_sign_in_without_a_passkey_once()}</Dialog.Description
>
</Dialog.Header>
{#if oneTimeLink === null}
<div>
<Label for="expiration">Expiration</Label>
<Label for="expiration">{m.expiration()}</Label>
<Select.Root
selected={{
label: Object.keys(availableExpirations)[0],
@@ -75,10 +76,10 @@
</Select.Root>
</div>
<Button onclick={() => createOneTimeAccessToken()} disabled={!selectedExpiration}>
Generate Code
{m.generate_code()}
</Button>
{:else}
<Label for="login-code" class="sr-only">Login Code</Label>
<Label for="login-code" class="sr-only">{m.login_code()}</Label>
<Input id="login-code" value={oneTimeLink} readonly />
{/if}
</Dialog.Content>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import UserGroupService from '$lib/services/user-group-service';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { UserGroup } from '$lib/types/user-group.type';
@@ -34,7 +35,7 @@
items={groups}
{requestOptions}
onRefresh={async (o) => (groups = await userGroupService.list(o))}
columns={[{ label: 'Name', sortColumn: 'friendlyName' }]}
columns={[{ label: m.name(), sortColumn: 'friendlyName' }]}
bind:selectedIds={selectedGroupIds}
{selectionDisabled}
>

View File

@@ -1,4 +1,5 @@
<script>
import { m } from '$lib/paraglide/messages';
import Logo from './logo.svelte';
</script>
@@ -6,8 +7,8 @@
<div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="h-10 w-10" />
</div>
<p class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Browser unsupported</p>
<p class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">{m.browser_unsupported()}</p>
<p class="text-muted-foreground mt-3">
This browser doesn't support passkeys. Please or use a alternative sign in method.
{m.this_browser_does_not_support_passkeys()}
</p>
</div>

View File

@@ -1,9 +1,13 @@
import { setLocale } from '$lib/paraglide/runtime';
import type { User } from '$lib/types/user.type';
import { writable } from 'svelte/store';
const userStore = writable<User | null>(null);
const setUser = (user: User) => {
if (user.locale) {
setLocale(user.locale, { reload: false });
}
userStore.set(user);
};

View File

@@ -1,3 +1,4 @@
import type { Locale } from '$lib/paraglide/runtime';
import type { CustomClaim } from './custom-claim.type';
import type { UserGroup } from './user-group.type';
@@ -10,6 +11,7 @@ export type User = {
isAdmin: boolean;
userGroups: UserGroup[];
customClaims: CustomClaim[];
locale?: Locale;
ldapId?: string;
};

View File

@@ -1,10 +1,11 @@
import { m } from '$lib/paraglide/messages';
import { WebAuthnError } from '@simplewebauthn/browser';
import { AxiosError } from 'axios';
import { toast } from 'svelte-sonner';
export function getAxiosErrorMessage(
e: unknown,
defaultMessage: string = 'An unknown error occurred'
defaultMessage: string = m.an_unknown_error_occurred()
) {
let message = defaultMessage;
if (e instanceof AxiosError) {
@@ -13,29 +14,29 @@ export function getAxiosErrorMessage(
return message;
}
export function axiosErrorToast(e: unknown, defaultMessage: string = 'An unknown error occurred') {
export function axiosErrorToast(e: unknown, defaultMessage: string = m.an_unknown_error_occurred()) {
const message = getAxiosErrorMessage(e, defaultMessage);
toast.error(message);
}
export function getWebauthnErrorMessage(e: unknown) {
const errors = {
ERROR_CEREMONY_ABORTED: 'The authentication process was aborted',
ERROR_AUTHENTICATOR_GENERAL_ERROR: 'An error occurred with the authenticator',
ERROR_CEREMONY_ABORTED: m.authentication_process_was_aborted(),
ERROR_AUTHENTICATOR_GENERAL_ERROR: m.error_occurred_with_authenticator(),
ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT:
'The authenticator does not support discoverable credentials',
m.authenticator_does_not_support_discoverable_credentials(),
ERROR_AUTHENTICATOR_MISSING_RESIDENT_KEY_SUPPORT:
'The authenticator does not support resident keys',
ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED: 'This passkey was previously registered',
m.authenticator_does_not_support_resident_keys(),
ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED: m.passkey_was_previously_registered(),
ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG:
'The authenticator does not support any of the requested algorithms'
m.authenticator_does_not_support_any_of_the_requested_algorithms()
};
let message = 'An unknown error occurred';
let message = m.an_unknown_error_occurred();
if (e instanceof WebAuthnError && e.code in errors) {
message = errors[e.code as keyof typeof errors];
} else if (e instanceof WebAuthnError && e?.message.includes('timed out')) {
message = 'The authenticator timed out';
message = m.authenticator_timed_out();
} else if (e instanceof AxiosError && e.response?.data.error) {
message = e.response?.data.error;
} else {

View File

@@ -4,6 +4,7 @@
import Error from '$lib/components/error.svelte';
import Header from '$lib/components/header/header.svelte';
import { Toaster } from '$lib/components/ui/sonner';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { ModeWatcher } from 'mode-watcher';
@@ -30,10 +31,7 @@
</script>
{#if !appConfig}
<Error
message="A critical error occurred. Please contact your administrator."
showButton={false}
/>
<Error message={m.critical_error_occurred_contact_administrator()} showButton={false} />
{:else}
<Header />
{@render children()}

View File

@@ -14,6 +14,7 @@
import type { PageData } from './$types';
import ClientProviderImages from './components/client-provider-images.svelte';
import ScopeItem from './components/scope-item.svelte';
import { m } from '$lib/paraglide/messages';
const webauthnService = new WebAuthnService();
const oidService = new OidcService();
@@ -77,15 +78,15 @@
</script>
<svelte:head>
<title>Sign in to {client.name}</title>
<title>{m.sign_in_to({name: client.name})}</title>
</svelte:head>
{#if client == null}
<p>Client not found</p>
<p>{m.client_not_found()}</p>
{:else}
<SignInWrapper showAlternativeSignInMethodButton>
<ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">{m.sign_in_to({name: client.name})}</h1>
{#if errorMessage}
<p class="text-muted-foreground mb-10 mt-2">
{errorMessage}.
@@ -93,34 +94,36 @@
{/if}
{#if !authorizationRequired && !errorMessage}
<p class="text-muted-foreground mb-10 mt-2">
Do you want to sign in to <b>{client.name}</b> with your
<b>{$appConfigStore.appName}</b> account?
{@html m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
client: client.name,
appName: $appConfigStore.appName
})}
</p>
{:else if authorizationRequired}
<div transition:slide={{ duration: 300 }}>
<Card.Root class="mb-10 mt-6">
<Card.Header class="pb-5">
<p class="text-muted-foreground text-start">
<b>{client.name}</b> wants to access the following information:
{@html m.client_wants_to_access_the_following_information({ client: client.name })}
</p>
</Card.Header>
<Card.Content data-testid="scopes">
<div class="flex flex-col gap-3">
{#if scope!.includes('email')}
<ScopeItem icon={LucideMail} name="Email" description="View your email address" />
<ScopeItem icon={LucideMail} name={m.email()} description={m.view_your_email_address()} />
{/if}
{#if scope!.includes('profile')}
<ScopeItem
icon={LucideUser}
name="Profile"
description="View your profile information"
name={m.profile()}
description={m.view_your_profile_information()}
/>
{/if}
{#if scope!.includes('groups')}
<ScopeItem
icon={LucideUsers}
name="Groups"
description="View the groups you are a member of"
name={m.groups()}
description={m.view_the_groups_you_are_a_member_of()}
/>
{/if}
</div>
@@ -129,11 +132,11 @@
</div>
{/if}
<div class="flex w-full justify-stretch gap-2">
<Button onclick={() => history.back()} class="w-full" variant="secondary">Cancel</Button>
<Button onclick={() => history.back()} class="w-full" variant="secondary">{m.cancel()}</Button>
{#if !errorMessage}
<Button class="w-full" {isLoading} on:click={authorize}>Sign in</Button>
<Button class="w-full" {isLoading} on:click={authorize}>{m.sign_in()}</Button>
{:else}
<Button class="w-full" on:click={() => (errorMessage = null)}>Try again</Button>
<Button class="w-full" on:click={() => (errorMessage = null)}>{m.try_again()}</Button>
{/if}
</div>
</SignInWrapper>

View File

@@ -3,6 +3,7 @@
import CheckmarkAnimated from '$lib/icons/checkmark-animated.svelte';
import ConnectArrow from '$lib/icons/connect-arrow.svelte';
import CrossAnimated from '$lib/icons/cross-animated.svelte';
import { m } from '$lib/paraglide/messages';
import type { OidcClientMetaData } from '$lib/types/oidc.type';
const {
@@ -61,7 +62,7 @@
class="h-10 w-10"
src="/api/oidc/clients/{client.id}/logo"
draggable={false}
alt="Client Logo"
alt={m.client_logo()}
/>
{:else}
<div class="flex h-10 w-10 items-center justify-center text-3xl font-bold">

View File

@@ -9,6 +9,7 @@
import { startAuthentication } from '@simplewebauthn/browser';
import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
import { m } from '$lib/paraglide/messages';
const webauthnService = new WebAuthnService();
let isLoading = $state(false);
@@ -32,7 +33,7 @@
</script>
<svelte:head>
<title>Sign In</title>
<title>{m.sign_in()}</title>
</svelte:head>
<SignInWrapper showAlternativeSignInMethodButton>
@@ -40,18 +41,18 @@
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
Sign in to {$appConfigStore.appName}
{m.sign_in_to_appname({ appName: $appConfigStore.appName})}
</h1>
{#if error}
<p class="text-muted-foreground mt-2" in:fade>
{error}. Please try to sign in again.
{error}. {m.please_try_to_sign_in_again()}
</p>
{:else}
<p class="text-muted-foreground mt-2" in:fade>
Authenticate yourself with your passkey to access the admin panel.
{m.authenticate_yourself_with_your_passkey_to_access_the_admin_panel()}
</p>
{/if}
<Button class="mt-10" {isLoading} on:click={authenticate}
>{error ? 'Try again' : 'Authenticate'}</Button
>{error ? m.try_again() : m.authenticate()}</Button
>
</SignInWrapper>

View File

@@ -4,14 +4,15 @@
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import { LucideChevronRight, LucideMail, LucideRectangleEllipsis } from 'lucide-svelte';
const methods = [
{
icon: LucideRectangleEllipsis,
title: 'Login Code',
description: 'Enter a login code to sign in.',
title: m.login_code(),
description: m.enter_a_login_code_to_sign_in(),
href: '/login/alternative/code'
}
];
@@ -19,15 +20,15 @@
if ($appConfigStore.emailOneTimeAccessEnabled) {
methods.push({
icon: LucideMail,
title: 'Email Login',
description: 'Request a login code via email.',
title: m.email_login(),
description: m.request_a_login_code_via_email(),
href: '/login/alternative/email'
});
}
</script>
<svelte:head>
<title>Sign In</title>
<title>{m.sign_in()}</title>
</svelte:head>
<SignInWrapper>
@@ -35,9 +36,9 @@
<div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="h-10 w-10" />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Alternative Sign In</h1>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">{m.alternative_sign_in()}</h1>
<p class="text-muted-foreground mt-3">
If you dont't have access to your passkey, you can sign in using one of the following methods.
{m.if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods()}
</p>
<div class="mt-5 flex flex-col gap-3">
{#each methods as method}
@@ -59,7 +60,7 @@
</div>
<a class="text-muted-foreground mt-5 text-xs" href={'/login' + page.url.search}
>Use your passkey instead?</a
>{m.use_your_passkey_instead()}</a
>
</div>
</SignInWrapper>

View File

@@ -9,6 +9,7 @@
import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let code = $state(data.code ?? '');
@@ -26,7 +27,7 @@
try {
goto(data.redirect);
} catch (e) {
error = 'Invalid redirect URL';
error = m.invalid_redirect_url();
}
} catch (e) {
error = getAxiosErrorMessage(e);
@@ -43,20 +44,20 @@
</script>
<svelte:head>
<title>Login Code</title>
<title>{m.login_code()}</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-4xl font-bold">Login Code</h1>
<h1 class="font-playfair mt-5 text-4xl font-bold">{m.login_code()}</h1>
{#if error}
<p class="text-muted-foreground mt-2">
{error}. Please try again.
{error}. {m.please_try_again()}
</p>
{:else}
<p class="text-muted-foreground mt-2">Enter the code you received to sign in.</p>
<p class="text-muted-foreground mt-2">{m.enter_the_code_you_received_to_sign_in()}</p>
{/if}
<form
onsubmit={(e) => {
@@ -65,10 +66,10 @@
}}
class="w-full max-w-[450px]"
>
<Input id="Email" class="mt-7" placeholder="Code" bind:value={code} type="text" />
<Input id="Email" class="mt-7" placeholder={m.code()} bind:value={code} type="text" />
<div class="mt-8 flex justify-stretch gap-2">
<Button variant="secondary" class="w-full" href={"/login/alternative" + page.url.search}>Go back</Button>
<Button class="w-full" type="submit" {isLoading}>Submit</Button>
<Button variant="secondary" class="w-full" href={"/login/alternative" + page.url.search}>{m.go_back()}</Button>
<Button class="w-full" type="submit" {isLoading}>{m.submit()}</Button>
</div>
</form>
</SignInWrapper>

View File

@@ -6,6 +6,7 @@
import UserService from '$lib/services/user-service';
import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
import { m } from '$lib/paraglide/messages';
const { data } = $props();
@@ -21,38 +22,38 @@
await userService
.requestOneTimeAccessEmail(email, data.redirect)
.then(() => (success = true))
.catch((e) => (error = e.response?.data.error || 'An unknown error occurred'));
.catch((e) => (error = e.response?.data.error || m.an_unknown_error_occurred()));
isLoading = false;
}
</script>
<svelte:head>
<title>Email Login</title>
<title>{m.email_login()}</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator {success} error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Email Login</h1>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">{m.email_login()}</h1>
{#if error}
<p class="text-muted-foreground mt-2" in:fade>
{error}. Please try again.
{error}. {m.please_try_again()}
</p>
<div class="mt-10 flex w-full justify-stretch gap-2">
<Button variant="secondary" class="w-full" href="/">Go back</Button>
<Button class="w-full" onclick={() => (error = undefined)}>Try again</Button>
<Button variant="secondary" class="w-full" href="/">{m.go_back()}</Button>
<Button class="w-full" onclick={() => (error = undefined)}>{m.try_again()}</Button>
</div>
{:else if success}
<p class="text-muted-foreground mt-2" in:fade>
An email has been sent to the provided email, if it exists in the system.
{m.an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system()}
</p>
<div class="mt-8 flex w-full justify-stretch gap-2">
<Button variant="secondary" class="w-full" href={'/login/alternative' + page.url.search}
>Go back</Button
>{m.go_back()}</Button
>
<Button class="w-full" href={'/login/alternative/code' + page.url.search}>Enter code</Button>
<Button class="w-full" href={'/login/alternative/code' + page.url.search}>{m.enter_code()}</Button>
</div>
{:else}
<form
@@ -63,14 +64,14 @@
class="w-full max-w-[450px]"
>
<p class="text-muted-foreground mt-2" in:fade>
Enter your email address to receive an email with a login code.
{m.enter_your_email_address_to_receive_an_email_with_a_login_code()}
</p>
<Input id="Email" class="mt-7" placeholder="Your email" bind:value={email} />
<Input id="Email" class="mt-7" placeholder={m.your_email()} bind:value={email} />
<div class="mt-8 flex justify-stretch gap-2">
<Button variant="secondary" class="w-full" href={'/login/alternative' + page.url.search}
>Go back</Button
>{m.go_back()}</Button
>
<Button class="w-full" type="submit" {isLoading}>Submit</Button>
<Button class="w-full" type="submit" {isLoading}>{m.submit()}</Button>
</div>
</form>
{/if}

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store.js';
import userStore from '$lib/stores/user-store.js';
@@ -33,18 +34,16 @@
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-4xl font-bold">
{`${$appConfigStore.appName} Setup`}
{m.appname_setup({ appName: $appConfigStore.appName })}
</h1>
{#if error}
<p class="text-muted-foreground mt-2">
{error}. Please try again.
{error}. {m.please_try_again()}
</p>
{:else}
<p class="text-muted-foreground mt-2">
You're about to sign in to the initial admin account. Anyone with this link can access the
account until a passkey is added. Please set up a passkey as soon as possible to prevent
unauthorized access.
{m.you_are_about_to_sign_in_to_the_initial_admin_account()}
</p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
<Button class="mt-5" {isLoading} on:click={authenticate}>{m.continue()}</Button>
{/if}
</SignInWrapper>

View File

@@ -3,6 +3,7 @@
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store.js';
import { axiosErrorToast } from '$lib/utils/error-util.js';
@@ -22,7 +23,7 @@
</script>
<svelte:head>
<title>Logout</title>
<title>{m.logout()}</title>
</svelte:head>
<SignInWrapper>
@@ -31,13 +32,13 @@
<Logo class="h-10 w-10" />
</div>
</div>
<h1 class="font-playfair mt-5 text-4xl font-bold">Sign out</h1>
<h1 class="font-playfair mt-5 text-4xl font-bold">{m.sign_out()}</h1>
<p class="text-muted-foreground mt-2">
Do you want to sign out of Pocket ID with the account <b>{$userStore?.username}</b>?
{@html m.do_you_want_to_sign_out_of_pocketid_with_the_account({ username: $userStore?.username ?? '' })}
</p>
<div class="mt-10 flex w-full justify-stretch gap-2">
<Button class="w-full" variant="secondary" onclick={() => history.back()}>Cancel</Button>
<Button class="w-full" {isLoading} onclick={signOut}>Sign out</Button>
<Button class="w-full" variant="secondary" onclick={() => history.back()}>{m.cancel()}</Button>
<Button class="w-full" {isLoading} onclick={signOut}>{m.sign_out()}</Button>
</div>
</SignInWrapper>

View File

@@ -4,6 +4,7 @@
import { LucideExternalLink } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import { m } from '$lib/paraglide/messages';
let {
children,
@@ -16,19 +17,19 @@
const { versionInformation } = data;
let links = $state([
{ href: '/settings/account', label: 'My Account' },
{ href: '/settings/audit-log', label: 'Audit Log' }
{ href: '/settings/account', label: m.my_account() },
{ href: '/settings/audit-log', label: m.audit_log() }
]);
if ($userStore?.isAdmin) {
links = [
// svelte-ignore state_referenced_locally
...links,
{ href: '/settings/admin/users', label: 'Users' },
{ href: '/settings/admin/user-groups', label: 'User Groups' },
{ href: '/settings/admin/oidc-clients', label: 'OIDC Clients' },
{ href: '/settings/admin/api-keys', label: 'API Keys' },
{ href: '/settings/admin/application-configuration', label: 'Application Configuration' }
{ href: '/settings/admin/users', label: m.users() },
{ href: '/settings/admin/user-groups', label: m.user_groups() },
{ href: '/settings/admin/oidc-clients', label: m.oidc_clients() },
{ href: '/settings/admin/api-keys', label: m.api_keys() },
{ href: '/settings/admin/application-configuration', label: m.application_configuration() }
];
}
</script>
@@ -40,7 +41,7 @@
>
<div class="min-w-[200px] xl:min-w-[250px]">
<div class="mx-auto grid w-full gap-2">
<h1 class="mb-5 text-3xl font-semibold">Settings</h1>
<h1 class="mb-5 text-3xl font-semibold">{m.settings()}</h1>
</div>
<nav class="text-muted-foreground grid gap-4 text-sm">
{#each links as { href, label }}
@@ -54,7 +55,7 @@
target="_blank"
class="flex items-center gap-2"
>
Update Pocket ID <LucideExternalLink class="my-auto inline-block h-3 w-3" />
{m.update_pocket_id()} <LucideExternalLink class="my-auto inline-block h-3 w-3" />
</a>
{/if}
</nav>
@@ -65,7 +66,7 @@
</main>
<div class="flex flex-col items-center">
<p class="text-muted-foreground py-3 text-xs">
Powered by <a
{m.powered_by()} <a
class="text-foreground"
href="https://github.com/pocket-id/pocket-id"
target="_blank">Pocket ID</a

View File

@@ -2,6 +2,7 @@
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store';
@@ -13,6 +14,7 @@
import { toast } from 'svelte-sonner';
import ProfilePictureSettings from '../../../lib/components/form/profile-picture-settings.svelte';
import AccountForm from './account-form.svelte';
import LocalePicker from './locale-picker.svelte';
import LoginCodeModal from './login-code-modal.svelte';
import PasskeyList from './passkey-list.svelte';
import RenamePasskeyModal from './rename-passkey-modal.svelte';
@@ -39,7 +41,7 @@
let success = true;
await userService
.updateCurrent(user)
.then(() => toast.success('Account details updated successfully'))
.then(() => toast.success(m.account_details_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
@@ -51,9 +53,7 @@
async function updateProfilePicture(image: File) {
await userService
.updateCurrentUsersProfilePicture(image)
.then(() =>
toast.success('Profile picture updated successfully. It may take a few minutes to update.')
)
.then(() => toast.success(m.profile_picture_updated_successfully()))
.catch(axiosErrorToast);
}
@@ -72,24 +72,22 @@
</script>
<svelte:head>
<title>Account Settings</title>
<title>{m.account_settings()}</title>
</svelte:head>
{#if passkeys.length == 0}
<Alert.Root variant="warning">
<LucideAlertTriangle class="size-4" />
<Alert.Title>Passkey missing</Alert.Title>
<Alert.Title>{m.passkey_missing()}</Alert.Title>
<Alert.Description
>Please add a passkey to prevent losing access to your account.</Alert.Description
>{m.please_provide_a_passkey_to_prevent_losing_access_to_your_account()}</Alert.Description
>
</Alert.Root>
{:else if passkeys.length == 1}
<Alert.Root variant="warning" dismissibleId="single-passkey">
<LucideAlertTriangle class="size-4" />
<Alert.Title>Single Passkey Configured</Alert.Title>
<Alert.Description
>It is recommended to add more than one passkey to avoid losing access to your account.</Alert.Description
>
<Alert.Title>{m.single_passkey_configured()}</Alert.Title>
<Alert.Description>{m.it_is_recommended_to_add_more_than_one_passkey()}</Alert.Description>
</Alert.Root>
{/if}
@@ -99,7 +97,7 @@
>
<Card.Root>
<Card.Header>
<Card.Title>Account Details</Card.Title>
<Card.Title>{m.account_details()}</Card.Title>
</Card.Header>
<Card.Content>
<AccountForm {account} callback={updateAccount} />
@@ -122,12 +120,12 @@
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Passkeys</Card.Title>
<Card.Title>{m.passkeys()}</Card.Title>
<Card.Description class="mt-1">
Manage your passkeys that you can use to authenticate yourself.
{m.manage_your_passkeys_that_you_can_use_to_authenticate_yourself()}
</Card.Description>
</div>
<Button size="sm" class="ml-3" on:click={createPasskey}>Add Passkey</Button>
<Button size="sm" class="ml-3" on:click={createPasskey}>{m.add_passkey()}</Button>
</div>
</Card.Header>
{#if passkeys.length != 0}
@@ -141,12 +139,28 @@
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Login Code</Card.Title>
<Card.Title>{m.login_code()}</Card.Title>
<Card.Description class="mt-1">
Create a one-time login code to sign in from a different device without a passkey.
{m.create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey()}
</Card.Description>
</div>
<Button size="sm" class="ml-auto" on:click={() => (showLoginCodeModal = true)}>Create</Button>
<Button size="sm" class="ml-auto" on:click={() => (showLoginCodeModal = true)}
>{m.create()}</Button
>
</div>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>{m.language()}</Card.Title>
<Card.Description class="mt-1">
{m.select_the_language_you_want_to_use()}
</Card.Description>
</div>
<LocalePicker />
</div>
</Card.Header>
</Card.Root>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type { UserCreate } from '$lib/types/user.type';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
@@ -24,7 +25,7 @@
.max(30)
.regex(
/^[a-z0-9_@.-]+$/,
"Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols"
m.username_can_only_contain()
),
email: z.string().email(),
isAdmin: z.boolean()
@@ -36,7 +37,7 @@
const data = form.validate();
if (!data) return;
isLoading = true;
const success = await callback(data);
await callback(data);
// Reset form if user was successfully created
isLoading = false;
}
@@ -45,21 +46,21 @@
<form onsubmit={onSubmit}>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label="First name" bind:input={$inputs.firstName} />
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
</div>
<div class="w-full">
<FormInput label="Last name" bind:input={$inputs.lastName} />
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
</div>
</div>
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label="Email" bind:input={$inputs.email} />
<FormInput label={m.email()} bind:input={$inputs.email} />
</div>
<div class="w-full">
<FormInput label="Username" bind:input={$inputs.username} />
<FormInput label={m.username()} bind:input={$inputs.username} />
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</form>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import { getLocale, setLocale, type Locale } from '$lib/paraglide/runtime';
import UserService from '$lib/services/user-service';
import userStore from '$lib/stores/user-store';
const userService = new UserService();
const currentLocale = getLocale();
const locales = {
en: 'English',
nl: 'Nederlands'
};
function updateLocale(locale: Locale) {
setLocale(locale);
userService.updateCurrent({
...$userStore!,
locale
});
}
</script>
<Select.Root
selected={{
label: locales[currentLocale],
value: currentLocale
}}
onSelectedChange={(v) => updateLocale(v!.value)}
>
<Select.Trigger class="h-9 max-w-[200px]" aria-label="Select locale">
<Select.Value>{locales[currentLocale]}</Select.Value>
</Select.Trigger>
<Select.Content>
{#each Object.entries(locales) as [value, label]}
<Select.Item {value}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>

View File

@@ -3,6 +3,7 @@
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -37,9 +38,9 @@
<Dialog.Root open={!!code} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Login Code</Dialog.Title>
<Dialog.Title>{m.login_code()}</Dialog.Title>
<Dialog.Description
>Sign in using the following code. The code will expire in 15 minutes.
>{m.sign_in_using_the_following_code_the_code_will_expire_in_minutes()}
</Dialog.Description>
</Dialog.Header>
@@ -49,7 +50,7 @@
</CopyToClipboard>
<div class="text-muted-foreground flex items-center justify-center gap-3">
<Separator />
<p class="text-nowrap text-xs">or visit</p>
<p class="text-nowrap text-xs">{m.or_visit()}</p>
<Separator />
</div>
<div>

View File

@@ -8,6 +8,7 @@
import { LucideKeyRound, LucidePencil, LucideTrash } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import RenamePasskeyModal from './rename-passkey-modal.svelte';
import { m } from '$lib/paraglide/messages';
let { passkeys = $bindable() }: { passkeys: Passkey[] } = $props();
@@ -17,16 +18,16 @@
async function deletePasskey(passkey: Passkey) {
openConfirmDialog({
title: `Delete ${passkey.name}`,
message: 'Are you sure you want to delete this passkey?',
title: m.delete_passkey_name({ passkeyName: passkey.name }),
message: m.are_you_sure_you_want_to_delete_this_passkey(),
confirm: {
label: 'Delete',
label: m.delete(),
destructive: true,
action: async () => {
try {
await webauthnService.removeCredential(passkey.id);
passkeys = await webauthnService.listCredentials();
toast.success('Passkey deleted successfully');
toast.success(m.passkey_deleted_successfully());
} catch (e) {
axiosErrorToast(e);
}
@@ -44,7 +45,7 @@
<div>
<p>{passkey.name}</p>
<p class="text-xs text-muted-foreground">
Added on {new Date(passkey.createdAt).toLocaleDateString()}
{m.added_on()} {new Date(passkey.createdAt).toLocaleDateString()}
</p>
</div>
</div>
@@ -53,13 +54,13 @@
on:click={() => (passkeyToRename = passkey)}
size="sm"
variant="outline"
aria-label="Rename"><LucidePencil class="h-3 w-3" /></Button
aria-label={m.rename()}><LucidePencil class="h-3 w-3" /></Button
>
<Button
on:click={() => deletePasskey(passkey)}
size="sm"
variant="outline"
aria-label="Delete"><LucideTrash class="h-3 w-3 text-red-500" /></Button
aria-label={m.delete()}><LucideTrash class="h-3 w-3 text-red-500" /></Button
>
</div>
</div>

View File

@@ -3,6 +3,7 @@
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service';
import type { Passkey } from '$lib/types/passkey.type';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -35,7 +36,7 @@
.updateCredentialName(passkey!.id, name)
.then(() => {
passkey = null;
toast.success('Passkey name updated successfully');
toast.success(m.passkey_name_updated_successfully());
callback?.();
})
.catch(axiosErrorToast);
@@ -45,16 +46,16 @@
<Dialog.Root open={!!passkey} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Name Passkey</Dialog.Title>
<Dialog.Description>Name your passkey to easily identify it later.</Dialog.Description>
<Dialog.Title>{m.name_passkey()}</Dialog.Title>
<Dialog.Description>{m.name_your_passkey_to_easily_identify_it_later()}</Dialog.Description>
</Dialog.Header>
<form onsubmit={onSubmit}>
<div class="grid items-center gap-4 sm:grid-cols-4">
<Label for="name" class="sm:text-right">Name</Label>
<Label for="name" class="sm:text-right">{m.name()}</Label>
<Input id="name" bind:value={name} class="col-span-3" />
</div>
<Dialog.Footer class="mt-4">
<Button type="submit">Save</Button>
<Button type="submit">{m.save()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>

View File

@@ -9,6 +9,7 @@
import ApiKeyDialog from './api-key-dialog.svelte';
import ApiKeyForm from './api-key-form.svelte';
import ApiKeyList from './api-key-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let apiKeys = $state(data.apiKeys);
@@ -35,18 +36,18 @@
</script>
<svelte:head>
<title>API Keys</title>
<title>{m.api_keys()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Create API Key</Card.Title>
<Card.Description>Add a new API key for programmatic access.</Card.Description>
<Card.Title>{m.create_api_key()}</Card.Title>
<Card.Description>{m.add_a_new_api_key_for_programmatic_access()}</Card.Description>
</div>
{#if !expandAddApiKey}
<Button on:click={() => (expandAddApiKey = true)}>Add API Key</Button>
<Button on:click={() => (expandAddApiKey = true)}>{m.add_api_key()}</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddApiKey = false)}>
<LucideMinus class="h-5 w-5" />
@@ -65,7 +66,7 @@
<Card.Root class="mt-6">
<Card.Header>
<Card.Title>Manage API Keys</Card.Title>
<Card.Title>{m.manage_api_keys()}</Card.Title>
</Card.Header>
<Card.Content>
<ApiKeyList {apiKeys} requestOptions={apiKeysRequestOptions} />

View File

@@ -2,6 +2,7 @@
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { m } from '$lib/paraglide/messages';
import type { ApiKeyResponse } from '$lib/types/api-key.type';
let {
@@ -20,22 +21,22 @@
<Dialog.Root open={!!apiKeyResponse} {onOpenChange}>
<Dialog.Content class="max-w-md" closeButton={false}>
<Dialog.Header>
<Dialog.Title>API Key Created</Dialog.Title>
<Dialog.Title>{m.api_key_created()}</Dialog.Title>
<Dialog.Description>
For security reasons, this key will only be shown once. Please store it securely.
{m.for_security_reasons_this_key_will_only_be_shown_once()}
</Dialog.Description>
</Dialog.Header>
{#if apiKeyResponse}
<div>
<div class="mb-2 font-medium">Name</div>
<div class="mb-2 font-medium">{m.name()}</div>
<p class="text-muted-foreground">{apiKeyResponse.apiKey.name}</p>
{#if apiKeyResponse.apiKey.description}
<div class="mb-2 mt-4 font-medium">Description</div>
<div class="mb-2 mt-4 font-medium">{m.description()}</div>
<p class="text-muted-foreground">{apiKeyResponse.apiKey.description}</p>
{/if}
<div class="mb-2 mt-4 font-medium">API Key</div>
<div class="mb-2 mt-4 font-medium">{m.api_key()}</div>
<div class="bg-muted rounded-md p-2">
<CopyToClipboard value={apiKeyResponse.token}>
<span class="break-all font-mono text-sm">{apiKeyResponse.token}</span>
@@ -44,7 +45,7 @@
</div>
{/if}
<Dialog.Footer class="mt-3">
<Button variant="default" on:click={() => onOpenChange(false)}>Close</Button>
<Button variant="default" on:click={() => onOpenChange(false)}>{m.close()}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type { ApiKeyCreate } from '$lib/types/api-key.type';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
@@ -26,10 +27,10 @@
const formSchema = z.object({
name: z
.string()
.min(3, 'Name must be at least 3 characters')
.max(50, 'Name cannot exceed 50 characters'),
.min(3, m.name_must_be_at_least_3_characters())
.max(50, m.name_cannot_exceed_50_characters()),
description: z.string().default(''),
expiresAt: z.date().min(new Date(), 'Expiration date must be in the future')
expiresAt: z.date().min(new Date(), m.expiration_date_must_be_in_the_future())
});
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, apiKey);
@@ -54,25 +55,25 @@
<form onsubmit={onSubmit}>
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput
label="Name"
label={m.name()}
bind:input={$inputs.name}
description="Name to identify this API key."
description={m.name_to_identify_this_api_key()}
/>
<FormInput
label="Expires At"
label={m.expires_at()}
type="date"
description="When this API key will expire."
description={m.when_this_api_key_will_expire()}
bind:input={$inputs.expiresAt}
/>
<div class="col-span-1 md:col-span-2">
<FormInput
label="Description"
description="Optional description to help identify this key's purpose."
label={m.description()}
description={m.optional_description_to_help_identify_this_keys_purpose()}
bind:input={$inputs.description}
/>
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</form>

View File

@@ -3,6 +3,7 @@
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import { Button } from '$lib/components/ui/button';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import ApiKeyService from '$lib/services/api-key-service';
import type { ApiKey } from '$lib/types/api-key.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
@@ -21,22 +22,22 @@
const apiKeyService = new ApiKeyService();
function formatDate(dateStr: string | undefined) {
if (!dateStr) return 'Never';
if (!dateStr) return m.never();
return new Date(dateStr).toLocaleString();
}
function revokeApiKey(apiKey: ApiKey) {
openConfirmDialog({
title: 'Revoke API Key',
message: `Are you sure you want to revoke the API key "${apiKey.name}"? This will break any integrations using this key.`,
title: m.revoke_api_key(),
message: m.are_you_sure_you_want_to_revoke_the_api_key_apikeyname({ apiKeyName: apiKey.name }),
confirm: {
label: 'Revoke',
label: m.revoke(),
destructive: true,
action: async () => {
try {
await apiKeyService.revoke(apiKey.id);
apiKeys = await apiKeyService.list(requestOptions);
toast.success('API key revoked successfully');
toast.success(m.api_key_revoked_successfully());
} catch (e) {
axiosErrorToast(e);
}
@@ -52,11 +53,11 @@
onRefresh={async (o) => (apiKeys = await apiKeyService.list(o))}
withoutSearch
columns={[
{ label: 'Name', sortColumn: 'name' },
{ label: 'Description' },
{ label: 'Expires At', sortColumn: 'expiresAt' },
{ label: 'Last Used', sortColumn: 'lastUsedAt' },
{ label: 'Actions', hidden: true }
{ label: m.name(), sortColumn: 'name' },
{ label: m.description() },
{ label: m.expires_at(), sortColumn: 'expiresAt' },
{ label: m.last_used(), sortColumn: 'lastUsedAt' },
{ label: m.actions(), hidden: true }
]}
>
{#snippet rows({ item })}
@@ -65,7 +66,7 @@
<Table.Cell>{formatDate(item.expiresAt)}</Table.Cell>
<Table.Cell>{formatDate(item.lastUsedAt)}</Table.Cell>
<Table.Cell class="flex justify-end">
<Button on:click={() => revokeApiKey(item)} size="sm" variant="outline" aria-label="Revoke"
<Button on:click={() => revokeApiKey(item)} size="sm" variant="outline" aria-label={m.revoke()}
><LucideBan class="h-3 w-3 text-red-500" /></Button
>
</Table.Cell>

View File

@@ -9,6 +9,7 @@
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
import AppConfigLdapForm from './forms/app-config-ldap-form.svelte';
import UpdateApplicationImages from './update-application-images.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let appConfig = $state(data.appConfig);
@@ -46,36 +47,35 @@
: Promise.resolve();
await Promise.all([lightLogoPromise, darkLogoPromise, backgroundImagePromise, faviconPromise])
.then(() => toast.success('Images updated successfully'))
.then(() => toast.success(m.images_updated_successfully()))
.catch(axiosErrorToast);
}
</script>
<svelte:head>
<title>Application Configuration</title>
<title>{m.application_configuration()}</title>
</svelte:head>
<CollapsibleCard id="application-configuration-general" title="General" defaultExpanded>
<CollapsibleCard id="application-configuration-general" title={m.general()} defaultExpanded>
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<CollapsibleCard
id="application-configuration-email"
title="Email"
description="Enable email notifications to alert users when a login is detected from a new device or
location."
title={m.email()}
description={m.enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location()}
>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<CollapsibleCard
id="application-configuration-ldap"
title="LDAP"
description="Configure LDAP settings to sync users and groups from an LDAP server."
title={m.ldap()}
description={m.configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server()}
>
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<CollapsibleCard id="application-configuration-images" title="Images">
<CollapsibleCard id="application-configuration-images" title={m.images()}>
<UpdateApplicationImages callback={updateImages} />
</CollapsibleCard>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import FileInput from '$lib/components/form/file-input.svelte';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import type { HTMLAttributes } from 'svelte/elements';
@@ -60,7 +61,7 @@
<span
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform font-medium opacity-0 transition-opacity group-hover:opacity-100"
>
Update
{m.update()}
</span>
</div>
</FileInput>

View File

@@ -6,6 +6,7 @@
import { Button } from '$lib/components/ui/button';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util';
@@ -55,7 +56,7 @@
appConfig[key] = value;
});
toast.success('Email configuration updated successfully');
toast.success(m.email_configuration_updated_successfully());
return true;
}
async function onTestEmail() {
@@ -64,11 +65,11 @@
if (hasChanges) {
openConfirmDialog({
title: 'Save changes?',
title: m.save_changes_question(),
message:
'You have to save the changes before sending a test email. Do you want to save now?',
m.you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now(),
confirm: {
label: 'Save and send',
label: m.save_and_send(),
action: async () => {
const saved = await onSubmit();
if (saved) {
@@ -86,9 +87,9 @@
isSendingTestEmail = true;
await appConfigService
.sendTestEmail()
.then(() => toast.success('Test email sent successfully to your email address.'))
.then(() => toast.success(m.test_email_sent_successfully()))
.catch(() =>
toast.error('Failed to send test email. Check the server logs for more information.')
toast.error(m.failed_to_send_test_email())
)
.finally(() => (isSendingTestEmail = false));
}
@@ -96,21 +97,21 @@
<form onsubmit={onSubmit}>
<fieldset disabled={uiConfigDisabled}>
<h4 class="text-lg font-semibold">SMTP Configuration</h4>
<h4 class="text-lg font-semibold">{m.smtp_configuration()}</h4>
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
<FormInput label={m.smtp_host()} bind:input={$inputs.smtpHost} />
<FormInput label={m.smtp_port()} type="number" bind:input={$inputs.smtpPort} />
<FormInput label={m.smtp_user()} bind:input={$inputs.smtpUser} />
<FormInput label={m.smtp_password()} type="password" bind:input={$inputs.smtpPassword} />
<FormInput label={m.smtp_from()} bind:input={$inputs.smtpFrom} />
<div class="grid gap-2">
<Label class="mb-0" for="smtp-tls">SMTP TLS Option</Label>
<Label class="mb-0" for="smtp-tls">{m.smtp_tls_option()}</Label>
<Select.Root
selected={{ value: $inputs.smtpTls.value, label: tlsOptions[$inputs.smtpTls.value] }}
onSelectedChange={(v) => ($inputs.smtpTls.value = v!.value)}
>
<Select.Trigger>
<Select.Value placeholder="Email TLS Option" />
<Select.Value placeholder={m.email_tls_option()} />
</Select.Trigger>
<Select.Content>
<Select.Item value="none" label="None" />
@@ -121,31 +122,31 @@
</div>
<CheckboxWithLabel
id="skip-cert-verify"
label="Skip Certificate Verification"
description="This can be useful for self-signed certificates."
label={m.skip_certificate_verification()}
description={m.this_can_be_useful_for_selfsigned_certificates()}
bind:checked={$inputs.smtpSkipCertVerify.value}
/>
</div>
<h4 class="mt-10 text-lg font-semibold">Enabled Emails</h4>
<h4 class="mt-10 text-lg font-semibold">{m.enabled_emails()}</h4>
<div class="mt-4 flex flex-col gap-5">
<CheckboxWithLabel
id="email-login-notification"
label="Email Login Notification"
description="Send an email to the user when they log in from a new device."
label={m.email_login_notification()}
description={m.send_an_email_to_the_user_when_they_log_in_from_a_new_device()}
bind:checked={$inputs.emailLoginNotificationEnabled.value}
/>
<CheckboxWithLabel
id="email-login"
label="Email Login"
description="Allows users to sign in with a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry."
label={m.email_login()}
description={m.allow_users_to_sign_in_with_a_login_code_sent_to_their_email()}
bind:checked={$inputs.emailOneTimeAccessEnabled.value}
/>
</div>
</fieldset>
<div class="mt-8 flex flex-wrap justify-end gap-3">
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>Send test email</Button
>{m.send_test_email()}</Button
>
<Button type="submit" disabled={uiConfigDisabled}>Save</Button>
<Button type="submit" disabled={uiConfigDisabled}>{m.save()}</Button>
</div>
</form>

View File

@@ -3,6 +3,7 @@
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
@@ -39,35 +40,35 @@
if (!data) return;
isLoading = true;
await callback(data).finally(() => (isLoading = false));
toast.success('Application configuration updated successfully');
toast.success(m.application_configuration_updated_successfully());
}
</script>
<form onsubmit={onSubmit}>
<fieldset class="flex flex-col gap-5" disabled={uiConfigDisabled}>
<div class="flex flex-col gap-5">
<FormInput label="Application Name" bind:input={$inputs.appName} />
<FormInput label={m.application_name()} bind:input={$inputs.appName} />
<FormInput
label="Session Duration"
label={m.session_duration()}
type="number"
description="The duration of a session in minutes before the user has to sign in again."
description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
bind:input={$inputs.sessionDuration}
/>
<CheckboxWithLabel
id="self-account-editing"
label="Enable Self-Account Editing"
description="Whether the users should be able to edit their own account details."
label={m.enable_self_account_editing()}
description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()}
bind:checked={$inputs.allowOwnAccountEdit.value}
/>
<CheckboxWithLabel
id="emails-verified"
label="Emails Verified"
description="Whether the user's email should be marked as verified for the OIDC clients."
label={m.emails_verified()}
description={m.whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients()}
bind:checked={$inputs.emailsVerified.value}
/>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</fieldset>
</form>

View File

@@ -3,6 +3,7 @@
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -74,14 +75,14 @@
...data,
ldapEnabled: true
});
toast.success('LDAP configuration updated successfully');
toast.success(m.ldap_configuration_updated_successfully());
return true;
}
async function onDisable() {
ldapEnabled = false;
await callback({ ldapEnabled });
toast.success('LDAP disabled successfully');
toast.success(m.ldap_disabled_successfully());
}
async function onEnable() {
@@ -94,7 +95,7 @@
ldapSyncing = true;
await appConfigService
.syncLdap()
.then(() => toast.success('LDAP sync finished'))
.then(() => toast.success(m.ldap_sync_finished()))
.catch(axiosErrorToast);
ldapSyncing = false;
@@ -102,98 +103,98 @@
</script>
<form onsubmit={onSubmit}>
<h4 class="text-lg font-semibold">Client Configuration</h4>
<h4 class="text-lg font-semibold">{m.client_configuration()}</h4>
<fieldset disabled={uiConfigDisabled}>
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput
label="LDAP URL"
label={m.ldap_url()}
placeholder="ldap://example.com:389"
bind:input={$inputs.ldapUrl}
/>
<FormInput
label="LDAP Bind DN"
label={m.ldap_bind_dn()}
placeholder="cn=people,dc=example,dc=com"
bind:input={$inputs.ldapBindDn}
/>
<FormInput label="LDAP Bind Password" type="password" bind:input={$inputs.ldapBindPassword} />
<FormInput label={m.ldap_bind_password()} type="password" bind:input={$inputs.ldapBindPassword} />
<FormInput
label="LDAP Base DN"
label={m.ldap_base_dn()}
placeholder="dc=example,dc=com"
bind:input={$inputs.ldapBase}
/>
<FormInput
label="User Search Filter"
description="The Search filter to use to search/sync users."
label={m.user_search_filter()}
description={m.the_search_filter_to_use_to_search_or_sync_users()}
placeholder="(objectClass=person)"
bind:input={$inputs.ldapUserSearchFilter}
/>
<FormInput
label="Groups Search Filter"
description="The Search filter to use to search/sync groups."
label={m.groups_search_filter()}
description={m.the_search_filter_to_use_to_search_or_sync_groups()}
placeholder="(objectClass=groupOfNames)"
bind:input={$inputs.ldapUserGroupSearchFilter}
/>
<CheckboxWithLabel
id="skip-cert-verify"
label="Skip Certificate Verification"
description="This can be useful for self-signed certificates."
label={m.skip_certificate_verification()}
description={m.this_can_be_useful_for_selfsigned_certificates()}
bind:checked={$inputs.ldapSkipCertVerify.value}
/>
</div>
<h4 class="mt-10 text-lg font-semibold">Attribute Mapping</h4>
<h4 class="mt-10 text-lg font-semibold">{m.attribute_mapping()}</h4>
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
<FormInput
label="User Unique Identifier Attribute"
description="The value of this attribute should never change."
label={m.user_unique_identifier_attribute()}
description={m.the_value_of_this_attribute_should_never_change()}
placeholder="uuid"
bind:input={$inputs.ldapAttributeUserUniqueIdentifier}
/>
<FormInput
label="Username Attribute"
label={m.username_attribute()}
placeholder="uid"
bind:input={$inputs.ldapAttributeUserUsername}
/>
<FormInput
label="User Mail Attribute"
label={m.user_mail_attribute()}
placeholder="mail"
bind:input={$inputs.ldapAttributeUserEmail}
/>
<FormInput
label="User First Name Attribute"
label={m.user_first_name_attribute()}
placeholder="givenName"
bind:input={$inputs.ldapAttributeUserFirstName}
/>
<FormInput
label="User Last Name Attribute"
label={m.user_last_name_attribute()}
placeholder="sn"
bind:input={$inputs.ldapAttributeUserLastName}
/>
<FormInput
label="User Profile Picture Attribute"
description="The value of this attribute can either be a URL, a binary or a base64 encoded image."
label={m.user_profile_picture_attribute()}
description={m.the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image()}
placeholder="jpegPhoto"
bind:input={$inputs.ldapAttributeUserProfilePicture}
/>
<FormInput
label="Group Members Attribute"
description="The attribute to use for querying members of a group."
label={m.group_members_attribute()}
description={m.the_attribute_to_use_for_querying_members_of_a_group()}
placeholder="member"
bind:input={$inputs.ldapAttributeGroupMember}
/>
<FormInput
label="Group Unique Identifier Attribute"
description="The value of this attribute should never change."
label={m.group_unique_identifier_attribute()}
description={m.the_value_of_this_attribute_should_never_change()}
placeholder="uuid"
bind:input={$inputs.ldapAttributeGroupUniqueIdentifier}
/>
<FormInput
label="Group Name Attribute"
label={m.group_name_attribute()}
placeholder="cn"
bind:input={$inputs.ldapAttributeGroupName}
/>
<FormInput
label="Admin Group Name"
description="Members of this group will have Admin Privileges in Pocket ID."
label={m.admin_group_name()}
description={m.members_of_this_group_will_have_admin_privileges_in_pocketid()}
placeholder="_admin_group_name"
bind:input={$inputs.ldapAttributeAdminGroup}
/>
@@ -202,11 +203,11 @@
<div class="mt-8 flex flex-wrap justify-end gap-3">
{#if ldapEnabled}
<Button variant="secondary" onclick={onDisable} disabled={uiConfigDisabled}>Disable</Button>
<Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>Sync now</Button>
<Button type="submit" disabled={uiConfigDisabled}>Save</Button>
<Button variant="secondary" onclick={onDisable} disabled={uiConfigDisabled}>{m.disable()}</Button>
<Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>{m.sync_now()}</Button>
<Button type="submit" disabled={uiConfigDisabled}>{m.save()}</Button>
{:else}
<Button onclick={onEnable} disabled={uiConfigDisabled}>Enable</Button>
<Button onclick={onEnable} disabled={uiConfigDisabled}>{m.enable()}</Button>
{/if}
</div>
</form>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
import ApplicationImage from './application-image.svelte';
let {
@@ -23,7 +24,7 @@
<ApplicationImage
id="favicon"
imageClass="h-14 w-14 p-2"
label="Favicon"
label={m.favicon()}
bind:image={favicon}
imageURL="/api/application-configuration/favicon"
accept="image/x-icon"
@@ -31,7 +32,7 @@
<ApplicationImage
id="logo-light"
imageClass="h-32 w-32"
label="Light Mode Logo"
label={m.light_mode_logo()}
bind:image={logoLight}
imageURL="/api/application-configuration/logo?light=true"
forceColorScheme="light"
@@ -39,7 +40,7 @@
<ApplicationImage
id="logo-dark"
imageClass="h-32 w-32"
label="Dark Mode Logo"
label={m.dark_mode_logo()}
bind:image={logoDark}
imageURL="/api/application-configuration/logo?light=false"
forceColorScheme="dark"
@@ -47,13 +48,13 @@
<ApplicationImage
id="background-image"
imageClass="h-[350px] max-w-[500px]"
label="Background Image"
label={m.background_image()}
bind:image={backgroundImage}
imageURL="/api/application-configuration/background-image"
/>
</div>
<div class="flex justify-end">
<Button class="mt-5" onclick={() => callback(logoLight, logoDark, backgroundImage, favicon)}
>Save</Button
>{m.save()}</Button
>
</div>

View File

@@ -12,6 +12,7 @@
import { slide } from 'svelte/transition';
import OIDCClientForm from './oidc-client-form.svelte';
import OIDCClientList from './oidc-client-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let clients = $state(data.clients);
@@ -29,7 +30,7 @@
const clientSecret = await oidcService.createClientSecret(createdClient.id);
clientSecretStore.set(clientSecret);
goto(`/settings/admin/oidc-clients/${createdClient.id}`);
toast.success('OIDC client created successfully');
toast.success(m.oidc_client_created_successfully());
return true;
} catch (e) {
axiosErrorToast(e);
@@ -39,18 +40,18 @@
</script>
<svelte:head>
<title>OIDC Clients</title>
<title>{m.oidc_clients()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Create OIDC Client</Card.Title>
<Card.Description>Add a new OIDC client to {$appConfigStore.appName}.</Card.Description>
<Card.Title>{m.create_oidc_client()}</Card.Title>
<Card.Description>{m.add_a_new_oidc_client_to_appname({ appName: $appConfigStore.appName})}</Card.Description>
</div>
{#if !expandAddClient}
<Button on:click={() => (expandAddClient = true)}>Add OIDC Client</Button>
<Button on:click={() => (expandAddClient = true)}>{m.add_oidc_client()}</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddClient = false)}>
<LucideMinus class="h-5 w-5" />
@@ -69,7 +70,7 @@
<Card.Root>
<Card.Header>
<Card.Title>Manage OIDC Clients</Card.Title>
<Card.Title>{m.manage_oidc_clients()}</Card.Title>
</Card.Header>
<Card.Content>
<OIDCClientList {clients} requestOptions={clientsRequestOptions} />

View File

@@ -16,6 +16,7 @@
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let client = $state({
@@ -27,13 +28,13 @@
const oidcService = new OidcService();
const setupDetails = $state({
'Authorization URL': `https://${$page.url.hostname}/authorize`,
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
'Logout URL': `https://${$page.url.hostname}/api/oidc/end-session`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
PKCE: client.pkceEnabled ? 'Enabled' : 'Disabled'
[m.authorization_url()]: `https://${$page.url.hostname}/authorize`,
[m.oidc_discovery_url()]: `https://${$page.url.hostname}/.well-known/openid-configuration`,
[m.token_url()]: `https://${$page.url.hostname}/api/oidc/token`,
[m.userinfo_url()]: `https://${$page.url.hostname}/api/oidc/userinfo`,
[m.logout_url()]: `https://${$page.url.hostname}/api/oidc/end-session`,
[m.certificate_url()]: `https://${$page.url.hostname}/.well-known/jwks.json`,
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled()
});
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
@@ -45,11 +46,11 @@
: Promise.resolve();
client.isPublic = updatedClient.isPublic;
setupDetails.PKCE = updatedClient.pkceEnabled ? 'Enabled' : 'Disabled';
setupDetails[m.pkce()] = updatedClient.pkceEnabled ? m.enabled() : m.disabled();
await Promise.all([dataPromise, imagePromise])
.then(() => {
toast.success('OIDC client updated successfully');
toast.success(m.oidc_client_updated_successfully());
})
.catch((e) => {
axiosErrorToast(e);
@@ -61,17 +62,17 @@
async function createClientSecret() {
openConfirmDialog({
title: 'Create new client secret',
title: m.create_new_client_secret(),
message:
'Are you sure you want to create a new client secret? The old one will be invalidated.',
m.are_you_sure_you_want_to_create_a_new_client_secret(),
confirm: {
label: 'Generate',
label: m.generate(),
destructive: true,
action: async () => {
try {
const clientSecret = await oidcService.createClientSecret(client.id);
clientSecretStore.set(clientSecret);
toast.success('New client secret created successfully');
toast.success(m.new_client_secret_created_successfully());
} catch (e) {
axiosErrorToast(e);
}
@@ -84,7 +85,7 @@
await oidcService
.updateAllowedUserGroups(client.id, allowedGroups)
.then(() => {
toast.success('Allowed user groups updated successfully');
toast.success(m.allowed_user_groups_updated_successfully());
})
.catch((e) => {
axiosErrorToast(e);
@@ -97,12 +98,12 @@
</script>
<svelte:head>
<title>OIDC Client {client.name}</title>
<title>{m.oidc_client_name({ name: client.name })}</title>
</svelte:head>
<div>
<a class="text-muted-foreground flex text-sm" href="/settings/admin/oidc-clients"
><LucideChevronLeft class="h-5 w-5" /> Back</a
><LucideChevronLeft class="h-5 w-5" /> {m.back()}</a
>
</div>
<Card.Root>
@@ -112,14 +113,14 @@
<Card.Content>
<div class="flex flex-col">
<div class="mb-2 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">Client ID</Label>
<Label class="mb-0 w-44">{m.client_id()}</Label>
<CopyToClipboard value={client.id}>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</CopyToClipboard>
</div>
{#if !client.isPublic}
<div class="mb-2 mt-1 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">Client secret</Label>
<Label class="mb-0 w-44">{m.client_secret()}</Label>
{#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}>
<span class="text-muted-foreground text-sm" data-testid="client-secret">
@@ -158,7 +159,7 @@
{#if !showAllDetails}
<div class="mt-4 flex justify-center">
<Button on:click={() => (showAllDetails = true)} size="sm" variant="ghost"
>Show more details</Button
>{m.show_more_details()}</Button
>
</div>
{/if}
@@ -172,11 +173,11 @@
</Card.Root>
<CollapsibleCard
id="allowed-user-groups"
title="Allowed User Groups"
description="Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client."
title={m.allowed_user_groups()}
description={m.add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups()}
>
<UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} />
<div class="mt-5 flex justify-end">
<Button on:click={() => updateUserGroupClients(client.allowedUserGroupIds)}>Save</Button>
<Button on:click={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
</div>
</CollapsibleCard>

View File

@@ -2,6 +2,7 @@
import * as Dialog from '$lib/components/ui/dialog';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import { m } from '$lib/paraglide/messages';
let {
oneTimeLink = $bindable()
@@ -19,13 +20,12 @@
<Dialog.Root open={!!oneTimeLink} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>One Time Link</Dialog.Title>
<Dialog.Title>{m.one_time_link()}</Dialog.Title>
<Dialog.Description
>Use this link to sign in once. This is needed for users who haven't added a passkey yet or
have lost it.</Dialog.Description
>{m.use_this_link_to_sign_in_once()}</Dialog.Description
>
</Dialog.Header>
<Label for="one-time-link">One Time Link</Label>
<Label for="one-time-link">{m.one_time_link()}</Label>
<Input id="one-time-link" value={oneTimeLink} readonly />
</Dialog.Content>
</Dialog.Root>

View File

@@ -2,6 +2,7 @@
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { m } from '$lib/paraglide/messages';
import { LucideMinus, LucidePlus } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
@@ -53,7 +54,7 @@
on:click={() => (callbackURLs = [...callbackURLs, ''])}
>
<LucidePlus class="mr-1 h-4 w-4" />
{callbackURLs.length === 0 ? 'Add' : 'Add another'}
{callbackURLs.length === 0 ? m.add() : m.add_another()}
</Button>
{/if}
</div>

View File

@@ -12,6 +12,7 @@
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
import { m } from '$lib/paraglide/messages';
let {
callback,
@@ -79,16 +80,16 @@
<form onsubmit={onSubmit}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-7 sm:flex-row">
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
<FormInput label={m.name()} class="w-full" bind:input={$inputs.name} />
<div></div>
<OidcCallbackUrlInput
label="Callback URLs"
label={m.callback_urls()}
class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error}
/>
<OidcCallbackUrlInput
label="Logout Callback URLs"
label={m.logout_callback_urls()}
class="w-full"
allowEmpty
bind:callbackURLs={$inputs.logoutCallbackURLs.value}
@@ -96,8 +97,8 @@
/>
<CheckboxWithLabel
id="public-client"
label="Public Client"
description="Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app."
label={m.public_client()}
description={m.public_clients_do_not_have_a_client_secret_and_use_pkce_instead()}
onCheckedChange={(v) => {
if (v == true) form.setValue('pkceEnabled', true);
}}
@@ -105,21 +106,21 @@
/>
<CheckboxWithLabel
id="pkce"
label="PKCE"
description="Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks."
label={m.pkce()}
description={m.public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks()}
disabled={$inputs.isPublic.value}
bind:checked={$inputs.pkceEnabled.value}
/>
</div>
<div class="mt-8">
<Label for="logo">Logo</Label>
<Label for="logo">{m.logo()}</Label>
<div class="mt-2 flex items-end gap-3">
{#if logoDataURL}
<div class="bg-muted h-32 w-32 rounded-2xl p-3">
<img
class="m-auto max-h-full max-w-full object-contain"
src={logoDataURL}
alt={`${$inputs.name.value} logo`}
alt={m.name_logo({name: $inputs.name.value})}
/>
</div>
{/if}
@@ -131,17 +132,17 @@
onchange={onLogoChange}
>
<Button variant="secondary">
{logoDataURL ? 'Change Logo' : 'Upload Logo'}
{logoDataURL ? m.change_logo() : m.upload_logo()}
</Button>
</FileInput>
{#if logoDataURL}
<Button variant="outline" on:click={resetLogo}>Remove Logo</Button>
<Button variant="outline" on:click={resetLogo}>{m.remove_logo()}</Button>
{/if}
</div>
</div>
</div>
<div class="w-full"></div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</form>

View File

@@ -10,6 +10,7 @@
import { LucidePencil, LucideTrash } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import OneTimeLinkModal from './client-secret.svelte';
import { m } from '$lib/paraglide/messages';
let {
clients = $bindable(),
@@ -25,16 +26,16 @@
async function deleteClient(client: OidcClient) {
openConfirmDialog({
title: `Delete ${client.name}`,
message: 'Are you sure you want to delete this OIDC client?',
title: m.delete_name({name: client.name}),
message: m.are_you_sure_you_want_to_delete_this_oidc_client(),
confirm: {
label: 'Delete',
label: m.delete(),
destructive: true,
action: async () => {
try {
await oidcService.removeClient(client.id);
clients = await oidcService.listClients(requestOptions!);
toast.success('OIDC client deleted successfully');
toast.success(m.oidc_client_deleted_successfully());
} catch (e) {
axiosErrorToast(e);
}
@@ -49,9 +50,9 @@
{requestOptions}
onRefresh={async (o) => (clients = await oidcService.listClients(o))}
columns={[
{ label: 'Logo' },
{ label: 'Name', sortColumn: 'name' },
{ label: 'Actions', hidden: true }
{ label: m.logo() },
{ label: m.name(), sortColumn: 'name' },
{ label: m.actions(), hidden: true }
]}
>
{#snippet rows({ item })}
@@ -61,7 +62,7 @@
<img
class="m-auto max-h-full max-w-full object-contain"
src="/api/oidc/clients/{item.id}/logo"
alt="{item.name} logo"
alt={m.name_logo({name: item.name})}
/>
</div>
{/if}
@@ -72,9 +73,9 @@
href="/settings/admin/oidc-clients/{item.id}"
size="sm"
variant="outline"
aria-label="Edit"><LucidePencil class="h-3 w-3 " /></Button
aria-label={m.edit()}><LucidePencil class="h-3 w-3 " /></Button
>
<Button on:click={() => deleteClient(item)} size="sm" variant="outline" aria-label="Delete"
<Button on:click={() => deleteClient(item)} size="sm" variant="outline" aria-label={m.delete()}
><LucideTrash class="h-3 w-3 text-red-500" /></Button
>
</Table.Cell>

View File

@@ -11,6 +11,7 @@
import { slide } from 'svelte/transition';
import UserGroupForm from './user-group-form.svelte';
import UserGroupList from './user-group-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let userGroups = $state(data.userGroups);
@@ -24,7 +25,7 @@
await userGroupService
.create(userGroup)
.then((createdUserGroup) => {
toast.success('User group created successfully');
toast.success(m.user_group_created_successfully());
goto(`/settings/admin/user-groups/${createdUserGroup.id}`);
})
.catch((e) => {
@@ -36,18 +37,18 @@
</script>
<svelte:head>
<title>User Groups</title>
<title>{m.user_groups()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Create User Group</Card.Title>
<Card.Description>Create a new group that can be assigned to users.</Card.Description>
<Card.Title>{m.create_user_group()}</Card.Title>
<Card.Description>{m.create_a_new_group_that_can_be_assigned_to_users()}</Card.Description>
</div>
{#if !expandAddUserGroup}
<Button on:click={() => (expandAddUserGroup = true)}>Add Group</Button>
<Button on:click={() => (expandAddUserGroup = true)}>{m.add_group()}</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUserGroup = false)}>
<LucideMinus class="h-5 w-5" />
@@ -66,7 +67,7 @@
<Card.Root>
<Card.Header>
<Card.Title>Manage User Groups</Card.Title>
<Card.Title>{m.manage_user_groups()}</Card.Title>
</Card.Header>
<Card.Content>
<UserGroupList {userGroups} requestOptions={userGroupsRequestOptions} />

View File

@@ -13,6 +13,7 @@
import { toast } from 'svelte-sonner';
import UserGroupForm from '../user-group-form.svelte';
import UserSelection from '../user-selection.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let userGroup = $state({
@@ -27,7 +28,7 @@
let success = true;
await userGroupService
.update(userGroup.id, updatedUserGroup)
.then(() => toast.success('User group updated successfully'))
.then(() => toast.success(m.user_group_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
@@ -39,7 +40,7 @@
async function updateUserGroupUsers(userIds: string[]) {
await userGroupService
.updateUsers(userGroup.id, userIds)
.then(() => toast.success('Users updated successfully'))
.then(() => toast.success(m.users_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
@@ -48,7 +49,7 @@
async function updateCustomClaims() {
await customClaimService
.updateUserGroupCustomClaims(userGroup.id, userGroup.customClaims)
.then(() => toast.success('Custom claims updated successfully'))
.then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
@@ -56,20 +57,20 @@
</script>
<svelte:head>
<title>User Group Details {userGroup.name}</title>
<title>{m.user_group_details_name({ name: userGroup.name })}</title>
</svelte:head>
<div class="flex items-center justify-between">
<a class="text-muted-foreground flex text-sm" href="/settings/admin/user-groups"
><LucideChevronLeft class="h-5 w-5" /> Back</a
><LucideChevronLeft class="h-5 w-5" /> {m.back()}</a
>
{#if !!userGroup.ldapId}
<Badge variant="default" class="">LDAP</Badge>
<Badge variant="default" class="">{m.ldap()}</Badge>
{/if}
</div>
<Card.Root>
<Card.Header>
<Card.Title>General</Card.Title>
<Card.Title>{m.general()}</Card.Title>
</Card.Header>
<Card.Content>
@@ -79,8 +80,8 @@
<Card.Root>
<Card.Header>
<Card.Title>Users</Card.Title>
<Card.Description>Assign users to this group.</Card.Description>
<Card.Title>{m.users()}</Card.Title>
<Card.Description>{m.assign_users_to_this_group()}</Card.Description>
</Card.Header>
<Card.Content>
@@ -91,7 +92,7 @@
<div class="mt-5 flex justify-end">
<Button
disabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
on:click={() => updateUserGroupUsers(userGroup.userIds)}>Save</Button
on:click={() => updateUserGroupUsers(userGroup.userIds)}>{m.save()}</Button
>
</div>
</Card.Content>
@@ -99,11 +100,11 @@
<CollapsibleCard
id="user-group-custom-claims"
title="Custom Claims"
description="Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts."
title={m.custom_claims()}
description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized()}
>
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button>
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</CollapsibleCard>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { createForm } from '$lib/utils/form-util';
@@ -60,23 +61,23 @@
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput
label="Friendly Name"
description="Name that will be displayed in the UI"
label={m.friendly_name()}
description={m.name_that_will_be_displayed_in_the_ui()}
bind:input={$inputs.friendlyName}
onInput={onFriendlyNameInput}
/>
</div>
<div class="w-full">
<FormInput
label="Name"
description={`Name that will be in the "groups" claim`}
label={m.name()}
description={m.name_that_will_be_in_the_groups_claim()}
bind:input={$inputs.name}
onInput={onNameInput}
/>
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</fieldset>
</form>

View File

@@ -5,6 +5,7 @@
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import UserGroupService from '$lib/services/user-group-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
@@ -26,16 +27,16 @@
async function deleteUserGroup(userGroup: UserGroup) {
openConfirmDialog({
title: `Delete ${userGroup.name}`,
message: 'Are you sure you want to delete this user group?',
title: m.delete_name({ name: userGroup.name }),
message: m.are_you_sure_you_want_to_delete_this_user_group(),
confirm: {
label: 'Delete',
label: m.delete(),
destructive: true,
action: async () => {
try {
await userGroupService.remove(userGroup.id);
userGroups = await userGroupService.list(requestOptions!);
toast.success('User group deleted successfully');
toast.success(m.user_group_deleted_successfully());
} catch (e) {
axiosErrorToast(e);
}
@@ -50,11 +51,11 @@
onRefresh={async (o) => (userGroups = await userGroupService.list(o))}
{requestOptions}
columns={[
{ label: 'Friendly Name', sortColumn: 'friendlyName' },
{ label: 'Name', sortColumn: 'name' },
{ label: 'User Count', sortColumn: 'userCount' },
...($appConfigStore.ldapEnabled ? [{ label: 'Source' }] : []),
{ label: 'Actions', hidden: true }
{ label: m.friendly_name(), sortColumn: 'friendlyName' },
{ label: m.name(), sortColumn: 'name' },
{ label: m.user_count(), sortColumn: 'userCount' },
...($appConfigStore.ldapEnabled ? [{ label: m.source() }] : []),
{ label: m.actions(), hidden: true }
]}
>
{#snippet rows({ item })}
@@ -63,7 +64,7 @@
<Table.Cell>{item.userCount}</Table.Cell>
{#if $appConfigStore.ldapEnabled}
<Table.Cell>
<Badge variant={item.ldapId ? 'default' : 'outline'}>{item.ldapId ? 'LDAP' : 'Local'}</Badge
<Badge variant={item.ldapId ? 'default' : 'outline'}>{item.ldapId ? m.ldap() : m.local()}</Badge
>
</Table.Cell>
{/if}
@@ -72,18 +73,18 @@
<DropdownMenu.Trigger asChild let:builder>
<Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}>
<Ellipsis class="h-4 w-4" />
<span class="sr-only">Toggle menu</span>
<span class="sr-only">{m.toggle_menu()}</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item href="/settings/admin/user-groups/{item.id}"
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
><LucidePencil class="mr-2 h-4 w-4" /> {m.edit()}</DropdownMenu.Item
>
{#if !item.ldapId || !$appConfigStore.ldapEnabled}
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
on:click={() => deleteUserGroup(item)}
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
><LucideTrash class="mr-2 h-4 w-4" />{m.delete()}</DropdownMenu.Item
>
{/if}
</DropdownMenu.Content>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { User } from '$lib/types/user.type';
@@ -35,8 +36,8 @@
onRefresh={async (o) => (users = await userService.list(o))}
{requestOptions}
columns={[
{ label: 'Name', sortColumn: 'firstName' },
{ label: 'Email', sortColumn: 'email' }
{ label: m.name(), sortColumn: 'firstName' },
{ label: m.email(), sortColumn: 'email' }
]}
bind:selectedIds={selectedUserIds}
{selectionDisabled}

View File

@@ -10,6 +10,7 @@
import { slide } from 'svelte/transition';
import UserForm from './user-form.svelte';
import UserList from './user-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let users = $state(data.users);
@@ -23,7 +24,7 @@
let success = true;
await userService
.create(user)
.then(() => toast.success('User created successfully'))
.then(() => toast.success(m.user_created_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
@@ -35,18 +36,18 @@
</script>
<svelte:head>
<title>Users</title>
<title>{m.users()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Create User</Card.Title>
<Card.Description>Add a new user to {$appConfigStore.appName}.</Card.Description>
<Card.Title>{m.create_user()}</Card.Title>
<Card.Description>{m.add_a_new_user_to_appname({ appName: $appConfigStore.appName })}.</Card.Description>
</div>
{#if !expandAddUser}
<Button on:click={() => (expandAddUser = true)}>Add User</Button>
<Button on:click={() => (expandAddUser = true)}>{m.add_user()}</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUser = false)}>
<LucideMinus class="h-5 w-5" />
@@ -65,7 +66,7 @@
<Card.Root>
<Card.Header>
<Card.Title>Manage Users</Card.Title>
<Card.Title>{m.manage_users()}</Card.Title>
</Card.Header>
<Card.Content>
<UserList {users} requestOptions={usersRequestOptions} />

View File

@@ -14,6 +14,7 @@
import { LucideChevronLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import UserForm from '../user-form.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let user = $state({
@@ -27,7 +28,7 @@
async function updateUserGroups(userIds: string[]) {
await userService
.updateUserGroups(user.id, userIds)
.then(() => toast.success('User groups updated successfully'))
.then(() => toast.success(m.user_groups_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
@@ -37,7 +38,7 @@
let success = true;
await userService
.update(user.id, updatedUser)
.then(() => toast.success('User updated successfully'))
.then(() => toast.success(m.user_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
@@ -49,7 +50,7 @@
async function updateCustomClaims() {
await customClaimService
.updateUserCustomClaims(user.id, user.customClaims)
.then(() => toast.success('Custom claims updated successfully'))
.then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
@@ -58,33 +59,38 @@
async function updateProfilePicture(image: File) {
await userService
.updateProfilePicture(user.id, image)
.then(() => toast.success('Profile picture updated successfully. It may take a few minutes to update.'))
.then(() => toast.success(m.profile_picture_updated_successfully()))
.catch(axiosErrorToast);
}
async function resetProfilePicture() {
await userService
.resetProfilePicture(user.id)
.then(() => toast.success('Profile picture has been reset. It may take a few minutes to update.'))
.then(() => toast.success(m.profile_picture_has_been_reset()))
.catch(axiosErrorToast);
}
</script>
<svelte:head>
<title>User Details {user.firstName} {user.lastName}</title>
<title
>{m.user_details_firstname_lastname({
firstName: user.firstName,
lastName: user.lastName
})}</title
>
</svelte:head>
<div class="flex items-center justify-between">
<a class="text-muted-foreground flex text-sm" href="/settings/admin/users"
><LucideChevronLeft class="h-5 w-5" /> Back</a
><LucideChevronLeft class="h-5 w-5" /> {m.back()}</a
>
{#if !!user.ldapId}
<Badge variant="default" class="">LDAP</Badge>
<Badge variant="default" class="">{m.ldap()}</Badge>
{/if}
</div>
<Card.Root>
<Card.Header>
<Card.Title>General</Card.Title>
<Card.Title>{m.general()}</Card.Title>
</Card.Header>
<Card.Content>
<UserForm existingUser={user} callback={updateUser} />
@@ -104,8 +110,8 @@
<CollapsibleCard
id="user-groups"
title="User Groups"
description="Manage which groups this user belongs to."
title={m.user_groups()}
description={m.manage_which_groups_this_user_belongs_to()}
>
<UserGroupSelection
bind:selectedGroupIds={user.userGroupIds}
@@ -115,18 +121,18 @@
<Button
on:click={() => updateUserGroups(user.userGroupIds)}
disabled={!!user.ldapId && $appConfigStore.ldapEnabled}
type="submit">Save</Button
type="submit">{m.save()}</Button
>
</div>
</CollapsibleCard>
<CollapsibleCard
id="user-custom-claims"
title="Custom Claims"
description="Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested."
title={m.custom_claims()}
description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user()}
>
<CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end">
<Button on:click={updateCustomClaims} type="submit">Save</Button>
<Button on:click={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</CollapsibleCard>

View File

@@ -2,6 +2,7 @@
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { User, UserCreate } from '$lib/types/user.type';
import { createForm } from '$lib/utils/form-util';
@@ -35,7 +36,7 @@
.max(30)
.regex(
/^[a-z0-9_@.-]+$/,
"Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols"
m.username_can_only_contain()
),
email: z.string().email(),
isAdmin: z.boolean()
@@ -57,19 +58,19 @@
<form onsubmit={onSubmit}>
<fieldset disabled={inputDisabled}>
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput label="First name" bind:input={$inputs.firstName} />
<FormInput label="Last name" bind:input={$inputs.lastName} />
<FormInput label="Username" bind:input={$inputs.username} />
<FormInput label="Email" bind:input={$inputs.email} />
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
<FormInput label={m.username()} bind:input={$inputs.username} />
<FormInput label={m.email()} bind:input={$inputs.email} />
<CheckboxWithLabel
id="admin-privileges"
label="Admin Privileges"
description="Admins have full access to the admin panel."
label={m.admin_privileges()}
description={m.admins_have_full_access_to_the_admin_panel()}
bind:checked={$inputs.isAdmin.value}
/>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</fieldset>
</form>

View File

@@ -15,6 +15,7 @@
import Ellipsis from 'lucide-svelte/icons/ellipsis';
import { toast } from 'svelte-sonner';
import OneTimeLinkModal from '$lib/components/one-time-link-modal.svelte';
import { m } from '$lib/paraglide/messages';
let {
users = $bindable(),
@@ -27,10 +28,10 @@
async function deleteUser(user: User) {
openConfirmDialog({
title: `Delete ${user.firstName} ${user.lastName}`,
message: 'Are you sure you want to delete this user?',
title: m.delete_firstname_lastname({firstName: user.firstName, lastName: user.lastName}),
message: m.are_you_sure_you_want_to_delete_this_user(),
confirm: {
label: 'Delete',
label: m.delete(),
destructive: true,
action: async () => {
try {
@@ -39,7 +40,7 @@
} catch (e) {
axiosErrorToast(e);
}
toast.success('User deleted successfully');
toast.success(m.user_deleted_successfully());
}
}
});
@@ -51,13 +52,13 @@
{requestOptions}
onRefresh={async (options) => (users = await userService.list(options))}
columns={[
{ label: 'First name', sortColumn: 'firstName' },
{ label: 'Last name', sortColumn: 'lastName' },
{ label: 'Email', sortColumn: 'email' },
{ label: 'Username', sortColumn: 'username' },
{ label: 'Role', sortColumn: 'isAdmin' },
...($appConfigStore.ldapEnabled ? [{ label: 'Source' }] : []),
{ label: 'Actions', hidden: true }
{ label: m.first_name(), sortColumn: 'firstName' },
{ label: m.last_name(), sortColumn: 'lastName' },
{ label: m.email(), sortColumn: 'email' },
{ label: m.username(), sortColumn: 'username' },
{ label: m.role(), sortColumn: 'isAdmin' },
...($appConfigStore.ldapEnabled ? [{ label: m.source()}] : []),
{ label: m.actions(), hidden: true }
]}
>
{#snippet rows({ item })}
@@ -66,11 +67,11 @@
<Table.Cell>{item.email}</Table.Cell>
<Table.Cell>{item.username}</Table.Cell>
<Table.Cell>
<Badge variant="outline">{item.isAdmin ? 'Admin' : 'User'}</Badge>
<Badge variant="outline">{item.isAdmin ? m.admin() : m.user()}</Badge>
</Table.Cell>
{#if $appConfigStore.ldapEnabled}
<Table.Cell>
<Badge variant={item.ldapId ? 'default' : 'outline'}>{item.ldapId ? 'LDAP' : 'Local'}</Badge
<Badge variant={item.ldapId ? 'default' : 'outline'}>{item.ldapId ? m.ldap() : m.local()}</Badge
>
</Table.Cell>
{/if}
@@ -78,20 +79,20 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
<Ellipsis class="h-4 w-4" />
<span class="sr-only">Toggle menu</span>
<span class="sr-only">{m.toggle_menu()}</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => (userIdToCreateOneTimeLink = item.id)}
><LucideLink class="mr-2 h-4 w-4" />Login Code</DropdownMenu.Item
><LucideLink class="mr-2 h-4 w-4" />{m.login_code()}</DropdownMenu.Item
>
<DropdownMenu.Item onclick={() => goto(`/settings/admin/users/${item.id}`)}
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
><LucidePencil class="mr-2 h-4 w-4" /> {m.edit()}</DropdownMenu.Item
>
{#if !item.ldapId || !$appConfigStore.ldapEnabled}
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
onclick={() => deleteUser(item)}
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
><LucideTrash class="mr-2 h-4 w-4" />{m.delete()}</DropdownMenu.Item
>
{/if}
</DropdownMenu.Content>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import AuditLogList from './audit-log-list.svelte';
let { data } = $props();
@@ -8,14 +9,14 @@
</script>
<svelte:head>
<title>Audit Log</title>
<title>{m.audit_log()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<Card.Title>Audit Log</Card.Title>
<Card.Title>{m.audit_log()}</Card.Title>
<Card.Description class="mt-1"
>See your account activities from the last 3 months.</Card.Description
>{m.see_your_account_activities_from_the_last_3_months()}</Card.Description
>
</Card.Header>
<Card.Content>

View File

@@ -2,6 +2,7 @@
import AdvancedTable from '$lib/components/advanced-table.svelte';
import { Badge } from '$lib/components/ui/badge';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import AuditLogService from '$lib/services/audit-log-service';
import type { AuditLog } from '$lib/types/audit-log.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
@@ -27,12 +28,12 @@
{requestOptions}
onRefresh={async (options) => (auditLogs = await auditLogService.list(options))}
columns={[
{ label: 'Time', sortColumn: 'createdAt' },
{ label: 'Event', sortColumn: 'event' },
{ label: 'Approximate Location', sortColumn: 'city' },
{ label: 'IP Address', sortColumn: 'ipAddress' },
{ label: 'Device', sortColumn: 'device' },
{ label: 'Client' }
{ label: m.time(), sortColumn: 'createdAt' },
{ label: m.event(), sortColumn: 'event' },
{ label: m.approximate_location(), sortColumn: 'city' },
{ label: m.ip_address(), sortColumn: 'ipAddress' },
{ label: m.device(), sortColumn: 'device' },
{ label: m.client() }
]}
withoutSearch
>
@@ -42,7 +43,7 @@
<Badge variant="outline">{toFriendlyEventString(item.event)}</Badge>
</Table.Cell>
<Table.Cell
>{item.city && item.country ? `${item.city}, ${item.country}` : 'Unknown'}</Table.Cell
>{item.city && item.country ? `${item.city}, ${item.country}` : m.unknown()}</Table.Cell
>
<Table.Cell>{item.ipAddress}</Table.Cell>
<Table.Cell>{item.device}</Table.Cell>