mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-10 05:34:19 +00:00
feat: support for url based icons (#840)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
85
frontend/src/lib/components/form/url-file-input.svelte
Normal file
85
frontend/src/lib/components/form/url-file-input.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import FileInput from '$lib/components/form/file-input.svelte';
|
||||
import FormattedMessage from '$lib/components/formatted-message.svelte';
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { LucideChevronDown } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
label,
|
||||
accept,
|
||||
onchange
|
||||
}: {
|
||||
label: string;
|
||||
accept?: string;
|
||||
onchange: (file: File | string | null) => void;
|
||||
} = $props();
|
||||
|
||||
let url = $state('');
|
||||
let hasError = $state(false);
|
||||
|
||||
async function handleFileChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0] || null;
|
||||
url = '';
|
||||
hasError = false;
|
||||
onchange(file);
|
||||
}
|
||||
|
||||
async function handleUrlChange(e: Event) {
|
||||
const url = (e.target as HTMLInputElement).value.trim();
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
hasError = false;
|
||||
} catch {
|
||||
hasError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
onchange(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex">
|
||||
<FileInput
|
||||
id="logo"
|
||||
variant="secondary"
|
||||
{accept}
|
||||
onchange={handleFileChange}
|
||||
onclick={(e: any) => (e.target.value = '')}
|
||||
>
|
||||
<Button variant="secondary" class="rounded-r-none">
|
||||
{label}
|
||||
</Button>
|
||||
</FileInput>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
class={cn(buttonVariants({ variant: 'secondary' }), 'rounded-l-none border-l')}
|
||||
>
|
||||
<LucideChevronDown class="size-4" /></Popover.Trigger
|
||||
>
|
||||
<Popover.Content class="w-80">
|
||||
<Label for="file-url" class="text-xs">URL</Label>
|
||||
<Input
|
||||
id="file-url"
|
||||
placeholder=""
|
||||
value={url}
|
||||
oninput={(e) => (url = e.currentTarget.value)}
|
||||
onfocusout={handleUrlChange}
|
||||
aria-invalid={hasError}
|
||||
/>
|
||||
{#if hasError}
|
||||
<p class="text-destructive mt-1 text-start text-xs">{m.invalid_url()}</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-muted-foreground mt-2 text-xs">
|
||||
<FormattedMessage m={m.logo_from_url_description()} />
|
||||
</p>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
@@ -1,10 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { LucideImageOff } from '@lucide/svelte';
|
||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||
|
||||
let props: HTMLImgAttributes & {} = $props();
|
||||
let error = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
props.src;
|
||||
error = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={'bg-muted flex items-center justify-center rounded-2xl p-3'}>
|
||||
<img class={cn('size-24 object-contain', props.class)} {...props} />
|
||||
{#if error}
|
||||
<LucideImageOff class={cn('text-muted-foreground p-5', props.class)} />
|
||||
{:else}
|
||||
<img
|
||||
{...props}
|
||||
class={cn('object-contain', props.class)}
|
||||
onerror={() => (error = true)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,8 @@ export type OidcClientUpdateWithLogo = OidcClientUpdate & {
|
||||
};
|
||||
|
||||
export type OidcClientCreateWithLogo = OidcClientCreate & {
|
||||
logo: File | null | undefined;
|
||||
logo?: File | null;
|
||||
logoUrl?: string;
|
||||
};
|
||||
|
||||
export type OidcDeviceCodeInfo = {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
accentColor: z.string()
|
||||
});
|
||||
|
||||
let { inputs, ...form } = $derived(createForm(formSchema, appConfig));
|
||||
let { inputs, ...form } = $derived(createForm(formSchema, updatedAppConfig));
|
||||
|
||||
async function onSubmit() {
|
||||
const data = form.validate();
|
||||
@@ -69,7 +69,6 @@
|
||||
description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()}
|
||||
bind:checked={$inputs.allowOwnAccountEdit.value}
|
||||
/>
|
||||
|
||||
<SwitchWithLabel
|
||||
id="emails-verified"
|
||||
label={m.emails_verified()}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script lang="ts">
|
||||
import FileInput from '$lib/components/form/file-input.svelte';
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||
import ImageBox from '$lib/components/image-box.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type {
|
||||
OidcClient,
|
||||
@@ -21,6 +18,7 @@
|
||||
import { z } from 'zod/v4';
|
||||
import FederatedIdentitiesInput from './federated-identities-input.svelte';
|
||||
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
|
||||
import OidcClientImageInput from './oidc-client-image-input.svelte';
|
||||
|
||||
let {
|
||||
callback,
|
||||
@@ -31,7 +29,6 @@
|
||||
callback: (client: OidcClientCreateWithLogo | OidcClientUpdateWithLogo) => Promise<boolean>;
|
||||
mode: 'create' | 'update';
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let showAdvancedOptions = $state(false);
|
||||
let logo = $state<File | null | undefined>();
|
||||
@@ -50,7 +47,8 @@
|
||||
launchURL: existingClient?.launchURL || '',
|
||||
credentials: {
|
||||
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
|
||||
}
|
||||
},
|
||||
logoUrl: ''
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -71,6 +69,7 @@
|
||||
pkceEnabled: z.boolean(),
|
||||
requiresReauthentication: z.boolean(),
|
||||
launchURL: optionalUrl,
|
||||
logoUrl: optionalUrl,
|
||||
credentials: z.object({
|
||||
federatedIdentities: z.array(
|
||||
z.object({
|
||||
@@ -90,30 +89,42 @@
|
||||
const data = form.validate();
|
||||
if (!data) return;
|
||||
isLoading = true;
|
||||
|
||||
const success = await callback({
|
||||
...data,
|
||||
logo
|
||||
logo: $inputs.logoUrl?.value ? null : logo,
|
||||
logoUrl: $inputs.logoUrl?.value
|
||||
});
|
||||
// Reset form if client was successfully created
|
||||
|
||||
const hasLogo = logo != null || !!$inputs.logoUrl?.value;
|
||||
if (success && existingClient && hasLogo) {
|
||||
logoDataURL = cachedOidcClientLogo.getUrl(existingClient.id);
|
||||
}
|
||||
|
||||
if (success && !existingClient) form.reset();
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
function onLogoChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0] || null;
|
||||
if (file) {
|
||||
logo = file;
|
||||
function onLogoChange(input: File | string | null) {
|
||||
if (input == null) return;
|
||||
|
||||
if (typeof input === 'string') {
|
||||
logo = null;
|
||||
logoDataURL = input || null;
|
||||
$inputs.logoUrl!.value = input;
|
||||
} else {
|
||||
logo = input;
|
||||
$inputs.logoUrl && ($inputs.logoUrl.value = '');
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
logoDataURL = event.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (event) => (logoDataURL = event.target?.result as string);
|
||||
reader.readAsDataURL(input);
|
||||
}
|
||||
}
|
||||
|
||||
function resetLogo() {
|
||||
logo = null;
|
||||
logoDataURL = null;
|
||||
$inputs.logoUrl && ($inputs.logoUrl.value = '');
|
||||
}
|
||||
|
||||
function getFederatedIdentityErrors(errors: z.ZodError<any> | undefined) {
|
||||
@@ -173,32 +184,13 @@
|
||||
bind:checked={$inputs.requiresReauthentication.value}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<Label for="logo">{m.logo()}</Label>
|
||||
<div class="mt-2 flex items-end gap-3">
|
||||
{#if logoDataURL}
|
||||
<ImageBox
|
||||
class="size-24"
|
||||
src={logoDataURL}
|
||||
alt={m.name_logo({ name: $inputs.name.value })}
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
<FileInput
|
||||
id="logo"
|
||||
variant="secondary"
|
||||
accept="image/png, image/jpeg, image/svg+xml, image/webp, image/avif, image/heic"
|
||||
onchange={onLogoChange}
|
||||
>
|
||||
<Button variant="secondary">
|
||||
{logoDataURL ? m.change_logo() : m.upload_logo()}
|
||||
</Button>
|
||||
</FileInput>
|
||||
{#if logoDataURL}
|
||||
<Button variant="outline" onclick={resetLogo}>{m.remove_logo()}</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-7">
|
||||
<OidcClientImageInput
|
||||
{logoDataURL}
|
||||
{resetLogo}
|
||||
clientName={$inputs.name.value}
|
||||
{onLogoChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if showAdvancedOptions}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import UrlFileInput from '$lib/components/form/url-file-input.svelte';
|
||||
import ImageBox from '$lib/components/image-box.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { LucideX } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
logoDataURL,
|
||||
clientName,
|
||||
resetLogo,
|
||||
onLogoChange
|
||||
}: {
|
||||
logoDataURL: string | null;
|
||||
clientName: string;
|
||||
resetLogo: () => void;
|
||||
onLogoChange: (file: File | string | null) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Label for="logo">{m.logo()}</Label>
|
||||
<div class="flex items-end gap-4">
|
||||
{#if logoDataURL}
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="relative shrink-0">
|
||||
<ImageBox class="size-24" src={logoDataURL} alt={m.name_logo({ name: clientName })} />
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onclick={resetLogo}
|
||||
class="absolute -top-2 -right-2 size-6 rounded-full shadow-md"
|
||||
>
|
||||
<LucideX class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UrlFileInput label={m.upload_logo()} accept="image/*" onchange={onLogoChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,16 +71,21 @@
|
||||
? item.allowedUserGroupsCount
|
||||
: m.unrestricted()}</Table.Cell
|
||||
>
|
||||
<Table.Cell class="flex justify-end gap-1">
|
||||
<Button
|
||||
href="/settings/admin/oidc-clients/{item.id}"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={m.edit()}><LucidePencil class="size-3 " /></Button
|
||||
>
|
||||
<Button onclick={() => deleteClient(item)} size="sm" variant="outline" aria-label={m.delete()}
|
||||
><LucideTrash class="size-3 text-red-500" /></Button
|
||||
>
|
||||
<Table.Cell class="align-middle">
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button
|
||||
href="/settings/admin/oidc-clients/{item.id}"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={m.edit()}><LucidePencil class="size-3 " /></Button
|
||||
>
|
||||
<Button
|
||||
onclick={() => deleteClient(item)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={m.delete()}><LucideTrash class="size-3 text-red-500" /></Button
|
||||
>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</AdvancedTable>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<div class="flex gap-3">
|
||||
<div class="aspect-square h-[56px]">
|
||||
<ImageBox
|
||||
class="grow rounded-lg object-contain"
|
||||
class="h-8 w-8 grow rounded-lg object-contain"
|
||||
src={client.hasLogo
|
||||
? cachedOidcClientLogo.getUrl(client.id)
|
||||
: cachedApplicationLogo.getUrl(isLightMode)}
|
||||
|
||||
Reference in New Issue
Block a user