1
0
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:
Kyle Mendell
2025-09-29 10:07:55 -05:00
committed by GitHub
parent 47bd5ba1ba
commit 6bdf5fa37a
19 changed files with 650 additions and 442 deletions

View 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>

View File

@@ -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>

View File

@@ -46,7 +46,8 @@ export type OidcClientUpdateWithLogo = OidcClientUpdate & {
};
export type OidcClientCreateWithLogo = OidcClientCreate & {
logo: File | null | undefined;
logo?: File | null;
logoUrl?: string;
};
export type OidcDeviceCodeInfo = {

View File

@@ -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()}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)}