mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-10 00:19:16 +00:00
feat: user application dashboard (#727)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { confirmDialogStore } from '.';
|
||||
import FormattedMessage from '../formatted-message.svelte';
|
||||
import Button from '../ui/button/button.svelte';
|
||||
</script>
|
||||
|
||||
@@ -9,7 +10,7 @@
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>{$confirmDialogStore.title}</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
{$confirmDialogStore.message}
|
||||
<FormattedMessage m={$confirmDialogStore.message} />
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
|
||||
@@ -8,53 +8,66 @@
|
||||
} = $props();
|
||||
|
||||
interface MessagePart {
|
||||
type: 'text' | 'link';
|
||||
type: 'text' | 'link' | 'bold';
|
||||
content: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
// Extracts attribute value from a tag's attribute string
|
||||
function getAttr(attrs: string, name: string): string | undefined {
|
||||
const re = new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, 'i');
|
||||
const m = re.exec(attrs ?? '');
|
||||
return m?.[2];
|
||||
}
|
||||
|
||||
const handlers: Record<string, (attrs: string, inner: string) => MessagePart | null> = {
|
||||
link: (attrs, inner) => {
|
||||
const href = getAttr(attrs, 'href');
|
||||
if (!href) return { type: 'text', content: inner };
|
||||
return { type: 'link', content: inner, href };
|
||||
},
|
||||
b: (_attrs, inner) => ({ type: 'bold', content: inner })
|
||||
};
|
||||
|
||||
function buildTokenRegex(): RegExp {
|
||||
const keys = Object.keys(handlers).join('|');
|
||||
// Matches: <tag attrs>inner</tag> for allowed tags only
|
||||
return new RegExp(`<(${keys})\\b([^>]*)>(.*?)<\\/\\1>`, 'g');
|
||||
}
|
||||
|
||||
function parseMessage(content: string): MessagePart[] | string {
|
||||
// Regex to match only <link href="url">text</link> format
|
||||
const linkRegex = /<link\s+href=(['"])(.*?)\1>(.*?)<\/link>/g;
|
||||
|
||||
if (!linkRegex.test(content)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Reset regex lastIndex for reuse
|
||||
linkRegex.lastIndex = 0;
|
||||
const tokenRegex = buildTokenRegex();
|
||||
if (!tokenRegex.test(content)) return content;
|
||||
// Reset lastIndex for reuse
|
||||
tokenRegex.lastIndex = 0;
|
||||
|
||||
const parts: MessagePart[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = linkRegex.exec(content)) !== null) {
|
||||
// Add text before the link
|
||||
while ((match = tokenRegex.exec(content)) !== null) {
|
||||
// Add text before the matched token
|
||||
if (match.index > lastIndex) {
|
||||
const textContent = content.slice(lastIndex, match.index);
|
||||
if (textContent) {
|
||||
parts.push({ type: 'text', content: textContent });
|
||||
}
|
||||
if (textContent) parts.push({ type: 'text', content: textContent });
|
||||
}
|
||||
|
||||
const href = match[2];
|
||||
const linkText = match[3];
|
||||
|
||||
parts.push({
|
||||
type: 'link',
|
||||
content: linkText,
|
||||
href: href
|
||||
});
|
||||
const tag = match[1];
|
||||
const attrs = match[2] ?? '';
|
||||
const inner = match[3] ?? '';
|
||||
const handler = handlers[tag];
|
||||
const part: MessagePart | null = handler
|
||||
? handler(attrs, inner)
|
||||
: { type: 'text', content: inner };
|
||||
if (part) parts.push(part);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining text after the last link
|
||||
// Add remaining text after the last token
|
||||
if (lastIndex < content.length) {
|
||||
const remainingText = content.slice(lastIndex);
|
||||
if (remainingText) {
|
||||
parts.push({ type: 'text', content: remainingText });
|
||||
}
|
||||
if (remainingText) parts.push({ type: 'text', content: remainingText });
|
||||
}
|
||||
|
||||
return parts;
|
||||
@@ -69,6 +82,10 @@
|
||||
{#each parsedContent as part}
|
||||
{#if part.type === 'text'}
|
||||
{part.content}
|
||||
{:else if part.type === 'bold'}
|
||||
<b>
|
||||
{part.content}
|
||||
</b>
|
||||
{:else if part.type === 'link'}
|
||||
<a
|
||||
class="text-black underline dark:text-white"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import WebAuthnService from '$lib/services/webauthn-service';
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||
import { LucideLogOut, LucideUser } from '@lucide/svelte';
|
||||
import { LayoutDashboard, LucideLogOut, LucideUser } from '@lucide/svelte';
|
||||
|
||||
const webauthnService = new WebAuthnService();
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Item onclick={() => goto('/settings/apps')}
|
||||
><LayoutDashboard class="mr-2 size-4" /> {m.my_apps()}</DropdownMenu.Item
|
||||
>
|
||||
<DropdownMenu.Item onclick={() => goto('/settings/account')}
|
||||
><LucideUser class="mr-2 size-4" /> {m.my_account()}</DropdownMenu.Item
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AuthorizedOidcClient,
|
||||
AuthorizeResponse,
|
||||
OidcClient,
|
||||
OidcClientCreate,
|
||||
@@ -113,6 +114,24 @@ class OidcService extends APIService {
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async listAuthorizedClients(options?: SearchPaginationSortRequest) {
|
||||
const res = await this.api.get('/oidc/users/me/clients', {
|
||||
params: options
|
||||
});
|
||||
return res.data as Paginated<AuthorizedOidcClient>;
|
||||
}
|
||||
|
||||
async listAuthorizedClientsForUser(userId: string, options?: SearchPaginationSortRequest) {
|
||||
const res = await this.api.get(`/oidc/users/${userId}/clients`, {
|
||||
params: options
|
||||
});
|
||||
return res.data as Paginated<AuthorizedOidcClient>;
|
||||
}
|
||||
|
||||
async revokeOwnAuthorizedClient(clientId: string) {
|
||||
await this.api.delete(`/oidc/users/me/clients/${clientId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default OidcService;
|
||||
|
||||
@@ -4,6 +4,7 @@ export type OidcClientMetaData = {
|
||||
id: string;
|
||||
name: string;
|
||||
hasLogo: boolean;
|
||||
launchURL?: string;
|
||||
};
|
||||
|
||||
export type OidcClientFederatedIdentity = {
|
||||
@@ -23,6 +24,7 @@ export type OidcClient = OidcClientMetaData & {
|
||||
isPublic: boolean;
|
||||
pkceEnabled: boolean;
|
||||
credentials?: OidcClientCredentials;
|
||||
launchURL?: string;
|
||||
};
|
||||
|
||||
export type OidcClientWithAllowedUserGroups = OidcClient & {
|
||||
@@ -50,3 +52,8 @@ export type AuthorizeResponse = {
|
||||
callbackURL: string;
|
||||
issuer: string;
|
||||
};
|
||||
|
||||
export type AuthorizedOidcClient = {
|
||||
scope: string;
|
||||
client: OidcClientMetaData;
|
||||
};
|
||||
|
||||
@@ -54,6 +54,13 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
|
||||
inputs[input as keyof z.infer<T>].error = null;
|
||||
}
|
||||
}
|
||||
// Update the input values with the parsed data
|
||||
for (const key in result.data) {
|
||||
if (Object.prototype.hasOwnProperty.call(inputs, key)) {
|
||||
inputs[key as keyof z.infer<T>].value = result.data[key];
|
||||
}
|
||||
}
|
||||
|
||||
return inputs;
|
||||
});
|
||||
return success ? data() : null;
|
||||
|
||||
11
frontend/src/lib/utils/zod-util.ts
Normal file
11
frontend/src/lib/utils/zod-util.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import z from 'zod/v4';
|
||||
|
||||
export const optionalString = z
|
||||
.string()
|
||||
.transform((v) => (v === '' ? undefined : v))
|
||||
.optional();
|
||||
|
||||
export const optionalUrl = z
|
||||
.url()
|
||||
.optional()
|
||||
.or(z.literal('').transform(() => undefined));
|
||||
Reference in New Issue
Block a user