mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-04 17:24:48 +00:00
feat: api key authentication (#291)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "0.37.0",
|
||||
"version": "0.38.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "0.37.0",
|
||||
"version": "0.38.0",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
@@ -16,7 +16,7 @@
|
||||
"crypto": "^1.0.1",
|
||||
"formsnap": "^1.0.1",
|
||||
"jose": "^5.9.6",
|
||||
"lucide-svelte": "^0.474.0",
|
||||
"lucide-svelte": "^0.479.0",
|
||||
"mode-watcher": "^0.5.1",
|
||||
"svelte-sonner": "^0.3.28",
|
||||
"sveltekit-superforms": "^2.23.1",
|
||||
@@ -25,6 +25,7 @@
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.7.0",
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@sveltejs/adapter-auto": "^4.0.0",
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
@@ -3300,9 +3301,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lucide-svelte": {
|
||||
"version": "0.474.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.474.0.tgz",
|
||||
"integrity": "sha512-yOSqjXPoEDOXCceBIfDaed6RinOvhp03ShiTXH6O+vlXE/NsyjQpktL8gm2vGDxi9d81HMuPFN1dwhVURh6mGg==",
|
||||
"version": "0.479.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.479.0.tgz",
|
||||
"integrity": "sha512-epCj6WL86ykxg7oCQTmPEth5e11pwJUzIfG9ROUsWsTP+WPtb3qat+VmAjfx/r4TRW7memTFcbTPvMrZvKthqw==",
|
||||
"peerDependencies": {
|
||||
"svelte": "^3 || ^4 || ^5.0.0-next.42"
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"crypto": "^1.0.1",
|
||||
"formsnap": "^1.0.1",
|
||||
"jose": "^5.9.6",
|
||||
"lucide-svelte": "^0.474.0",
|
||||
"lucide-svelte": "^0.479.0",
|
||||
"mode-watcher": "^0.5.1",
|
||||
"svelte-sonner": "^0.3.28",
|
||||
"sveltekit-superforms": "^2.23.1",
|
||||
@@ -30,6 +30,7 @@
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.7.0",
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@sveltejs/adapter-auto": "^4.0.0",
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
|
||||
53
frontend/src/lib/components/form/date-picker.svelte
Normal file
53
frontend/src/lib/components/form/date-picker.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Calendar } from '$lib/components/ui/calendar';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import {
|
||||
CalendarDate,
|
||||
DateFormatter,
|
||||
getLocalTimeZone,
|
||||
type DateValue
|
||||
} from '@internationalized/date';
|
||||
import CalendarIcon from 'lucide-svelte/icons/calendar';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let { value = $bindable(), ...restProps }: HTMLAttributes<HTMLButtonElement> & { value: Date } =
|
||||
$props();
|
||||
|
||||
let date: CalendarDate = $state(dateToCalendarDate(value));
|
||||
let open = $state(false);
|
||||
|
||||
function dateToCalendarDate(date: Date) {
|
||||
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
|
||||
}
|
||||
|
||||
function onValueChange(newDate?: DateValue) {
|
||||
if (!newDate) return;
|
||||
|
||||
value = newDate.toDate(getLocalTimeZone());
|
||||
date = newDate as CalendarDate;
|
||||
open = false;
|
||||
}
|
||||
|
||||
const df = new DateFormatter('en-US', {
|
||||
dateStyle: 'long'
|
||||
});
|
||||
</script>
|
||||
|
||||
<Popover.Root openFocus {open} onOpenChange={(o) => (open = o)}>
|
||||
<Popover.Trigger asChild let:builder>
|
||||
<Button
|
||||
{...restProps}
|
||||
variant="outline"
|
||||
class={cn('w-full justify-start text-left font-normal', !value && 'text-muted-foreground')}
|
||||
builders={[builder]}
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
{date ? df.format(date.toDate(getLocalTimeZone())) : 'Select a date'}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-auto p-0" align="start">
|
||||
<Calendar bind:value={date} initialFocus {onValueChange} />
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import DatePicker from '$lib/components/form/date-picker.svelte';
|
||||
import { Input, type FormInputEvent } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import type { FormInput } from '$lib/utils/form-util';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { Input, type FormInputEvent } from '$lib/components/ui/input';
|
||||
|
||||
let {
|
||||
input = $bindable(),
|
||||
@@ -16,12 +17,12 @@
|
||||
onInput,
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
input?: FormInput<string | boolean | number>;
|
||||
input?: FormInput<string | boolean | number | Date>;
|
||||
label?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
|
||||
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
|
||||
onInput?: (e: FormInputEvent) => void;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
@@ -34,20 +35,24 @@
|
||||
<Label class="mb-0" for={id}>{label}</Label>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||
<p class="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||
{/if}
|
||||
<div class={label || description ? 'mt-2' : ''}>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else if input}
|
||||
<Input
|
||||
{id}
|
||||
{placeholder}
|
||||
{type}
|
||||
bind:value={input.value}
|
||||
{disabled}
|
||||
on:input={(e) => onInput?.(e)}
|
||||
/>
|
||||
{#if type === 'date'}
|
||||
<DatePicker {id} bind:value={input.value as Date} />
|
||||
{:else}
|
||||
<Input
|
||||
{id}
|
||||
{placeholder}
|
||||
{type}
|
||||
bind:value={input.value}
|
||||
{disabled}
|
||||
on:input={(e) => onInput?.(e)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if input?.error}
|
||||
<p class="mt-1 text-sm text-red-500">{input.error}</p>
|
||||
|
||||
21
frontend/src/lib/components/ui/calendar/calendar-cell.svelte
Normal file
21
frontend/src/lib/components/ui/calendar/calendar-cell.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CalendarPrimitive.CellProps;
|
||||
|
||||
export let date: $$Props["date"];
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Cell
|
||||
{date}
|
||||
class={cn(
|
||||
"[&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-month])]:bg-accent/50 relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</CalendarPrimitive.Cell>
|
||||
42
frontend/src/lib/components/ui/calendar/calendar-day.svelte
Normal file
42
frontend/src/lib/components/ui/calendar/calendar-day.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CalendarPrimitive.DayProps;
|
||||
type $$Events = CalendarPrimitive.DayEvents;
|
||||
|
||||
export let date: $$Props["date"];
|
||||
export let month: $$Props["month"];
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Day
|
||||
on:click
|
||||
{date}
|
||||
{month}
|
||||
class={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal ",
|
||||
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground",
|
||||
// Selected
|
||||
"data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground data-[selected]:opacity-100",
|
||||
// Disabled
|
||||
"data-[disabled]:text-muted-foreground data-[disabled]:opacity-50",
|
||||
// Unavailable
|
||||
"data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through",
|
||||
// Outside months
|
||||
"data-[outside-month]:text-muted-foreground [&[data-outside-month][data-selected]]:bg-accent/50 [&[data-outside-month][data-selected]]:text-muted-foreground data-[outside-month]:pointer-events-none data-[outside-month]:opacity-50 [&[data-outside-month][data-selected]]:opacity-30",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
let:selected
|
||||
let:disabled
|
||||
let:unavailable
|
||||
let:builder
|
||||
>
|
||||
<slot {selected} {disabled} {unavailable} {builder}>
|
||||
{date.day}
|
||||
</slot>
|
||||
</CalendarPrimitive.Day>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CalendarPrimitive.GridBodyProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridBody class={cn(className)} {...$$restProps}>
|
||||
<slot />
|
||||
</CalendarPrimitive.GridBody>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CalendarPrimitive.GridHeadProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridHead class={cn(className)} {...$$restProps}>
|
||||
<slot />
|
||||
</CalendarPrimitive.GridHead>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CalendarPrimitive.GridRowProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridRow class={cn("flex", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</CalendarPrimitive.GridRow>
|
||||
13
frontend/src/lib/components/ui/calendar/calendar-grid.svelte
Normal file
13
frontend/src/lib/components/ui/calendar/calendar-grid.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CalendarPrimitive.GridProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Grid class={cn("w-full border-collapse space-y-1", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</CalendarPrimitive.Grid>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CalendarPrimitive.HeadCellProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.HeadCell
|
||||
class={cn("text-muted-foreground w-9 rounded-md text-[0.8rem] font-normal", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</CalendarPrimitive.HeadCell>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CalendarPrimitive.HeaderProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Header
|
||||
class={cn("relative flex w-full items-center justify-between pt-1", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</CalendarPrimitive.Header>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CalendarPrimitive.HeadingProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Heading
|
||||
let:headingValue
|
||||
class={cn("text-sm font-medium", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot {headingValue}>
|
||||
{headingValue}
|
||||
</slot>
|
||||
</CalendarPrimitive.Heading>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn("mt-4 flex flex-col space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import ChevronRight from "lucide-svelte/icons/chevron-right";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CalendarPrimitive.NextButtonProps;
|
||||
type $$Events = CalendarPrimitive.NextButtonEvents;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.NextButton
|
||||
on:click
|
||||
class={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
let:builder
|
||||
>
|
||||
<slot {builder}>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</slot>
|
||||
</CalendarPrimitive.NextButton>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import ChevronLeft from "lucide-svelte/icons/chevron-left";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CalendarPrimitive.PrevButtonProps;
|
||||
type $$Events = CalendarPrimitive.PrevButtonEvents;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.PrevButton
|
||||
on:click
|
||||
class={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
let:builder
|
||||
>
|
||||
<slot {builder}>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</slot>
|
||||
</CalendarPrimitive.PrevButton>
|
||||
141
frontend/src/lib/components/ui/calendar/calendar.svelte
Normal file
141
frontend/src/lib/components/ui/calendar/calendar.svelte
Normal file
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import * as Calendar from "$lib/components/ui/calendar/index.js";
|
||||
import * as Select from "$lib/components/ui/select/index.js";
|
||||
import { cn } from "$lib/utils/style";
|
||||
import {
|
||||
DateFormatter,
|
||||
getLocalTimeZone,
|
||||
today
|
||||
} from "@internationalized/date";
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
|
||||
type $$Props = CalendarPrimitive.Props;
|
||||
type $$Events = CalendarPrimitive.Events;
|
||||
|
||||
export let value: $$Props["value"] = undefined;
|
||||
export let placeholder: $$Props["placeholder"] = today(getLocalTimeZone());
|
||||
export let weekdayFormat: $$Props["weekdayFormat"] = "short";
|
||||
|
||||
const monthOptions = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December"
|
||||
].map((month, i) => ({ value: i + 1, label: month }));
|
||||
|
||||
const monthFmt = new DateFormatter("en-US", {
|
||||
month: "long"
|
||||
});
|
||||
|
||||
const yearOptions = Array.from({ length: 100 }, (_, i) => ({
|
||||
label: String(new Date().getFullYear() + i),
|
||||
value: new Date().getFullYear() + i
|
||||
}));
|
||||
|
||||
$: defaultYear = placeholder
|
||||
? {
|
||||
value: placeholder.year,
|
||||
label: String(placeholder.year)
|
||||
}
|
||||
: undefined;
|
||||
|
||||
$: defaultMonth = placeholder
|
||||
? {
|
||||
value: placeholder.month,
|
||||
label: monthFmt.format(placeholder.toDate(getLocalTimeZone()))
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Root
|
||||
{weekdayFormat}
|
||||
class={cn("rounded-md border p-3", className)}
|
||||
{...$$restProps}
|
||||
on:keydown
|
||||
let:months
|
||||
let:weekdays
|
||||
bind:value
|
||||
bind:placeholder
|
||||
>
|
||||
<Calendar.Header>
|
||||
<Calendar.Heading class="flex w-full items-center justify-between gap-2">
|
||||
<Select.Root
|
||||
selected={defaultMonth}
|
||||
items={monthOptions}
|
||||
onSelectedChange={(v) => {
|
||||
if (!v || !placeholder) return;
|
||||
if (v.value === placeholder?.month) return;
|
||||
placeholder = placeholder.set({ month: v.value });
|
||||
}}
|
||||
>
|
||||
<Select.Trigger aria-label="Select month" class="w-[60%]">
|
||||
<Select.Value placeholder="Select month" />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-[200px] overflow-y-auto">
|
||||
{#each monthOptions as { value, label }}
|
||||
<Select.Item {value} {label}>
|
||||
{label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Select.Root
|
||||
selected={defaultYear}
|
||||
items={yearOptions}
|
||||
onSelectedChange={(v) => {
|
||||
if (!v || !placeholder) return;
|
||||
if (v.value === placeholder?.year) return;
|
||||
placeholder = placeholder.set({ year: v.value });
|
||||
}}
|
||||
>
|
||||
<Select.Trigger aria-label="Select year" class="w-[40%]">
|
||||
<Select.Value placeholder="Select year" />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-[200px] overflow-y-auto">
|
||||
{#each yearOptions as { value, label }}
|
||||
<Select.Item {value} {label}>
|
||||
{label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Calendar.Heading>
|
||||
</Calendar.Header>
|
||||
<Calendar.Months>
|
||||
{#each months as month}
|
||||
<Calendar.Grid>
|
||||
<Calendar.GridHead>
|
||||
<Calendar.GridRow class="flex">
|
||||
{#each weekdays as weekday}
|
||||
<Calendar.HeadCell>
|
||||
{weekday.slice(0, 2)}
|
||||
</Calendar.HeadCell>
|
||||
{/each}
|
||||
</Calendar.GridRow>
|
||||
</Calendar.GridHead>
|
||||
<Calendar.GridBody>
|
||||
{#each month.weeks as weekDates}
|
||||
<Calendar.GridRow class="mt-2 w-full">
|
||||
{#each weekDates as date}
|
||||
<Calendar.Cell {date}>
|
||||
<Calendar.Day {date} month={month.value} />
|
||||
</Calendar.Cell>
|
||||
{/each}
|
||||
</Calendar.GridRow>
|
||||
{/each}
|
||||
</Calendar.GridBody>
|
||||
</Calendar.Grid>
|
||||
{/each}
|
||||
</Calendar.Months>
|
||||
30
frontend/src/lib/components/ui/calendar/index.ts
Normal file
30
frontend/src/lib/components/ui/calendar/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import Root from "./calendar.svelte";
|
||||
import Cell from "./calendar-cell.svelte";
|
||||
import Day from "./calendar-day.svelte";
|
||||
import Grid from "./calendar-grid.svelte";
|
||||
import Header from "./calendar-header.svelte";
|
||||
import Months from "./calendar-months.svelte";
|
||||
import GridRow from "./calendar-grid-row.svelte";
|
||||
import Heading from "./calendar-heading.svelte";
|
||||
import GridBody from "./calendar-grid-body.svelte";
|
||||
import GridHead from "./calendar-grid-head.svelte";
|
||||
import HeadCell from "./calendar-head-cell.svelte";
|
||||
import NextButton from "./calendar-next-button.svelte";
|
||||
import PrevButton from "./calendar-prev-button.svelte";
|
||||
|
||||
export {
|
||||
Day,
|
||||
Cell,
|
||||
Grid,
|
||||
Header,
|
||||
Months,
|
||||
GridRow,
|
||||
Heading,
|
||||
GridBody,
|
||||
GridHead,
|
||||
HeadCell,
|
||||
NextButton,
|
||||
PrevButton,
|
||||
//
|
||||
Root as Calendar,
|
||||
};
|
||||
@@ -4,10 +4,13 @@
|
||||
import * as Dialog from './index.js';
|
||||
import { cn, flyAndScale } from '$lib/utils/style.js';
|
||||
|
||||
type $$Props = DialogPrimitive.ContentProps;
|
||||
type $$Props = DialogPrimitive.ContentProps & {
|
||||
closeButton?: boolean;
|
||||
}
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let transition: $$Props['transition'] = flyAndScale;
|
||||
export let closeButton : $$Props['closeButton'] = true;
|
||||
export let transitionConfig: $$Props['transitionConfig'] = {
|
||||
duration: 200
|
||||
};
|
||||
@@ -26,11 +29,13 @@
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
{#if closeButton}
|
||||
<DialogPrimitive.Close
|
||||
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{/if}
|
||||
</DialogPrimitive.Content>
|
||||
</Dialog.Portal>
|
||||
|
||||
21
frontend/src/lib/services/api-key-service.ts
Normal file
21
frontend/src/lib/services/api-key-service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ApiKey, ApiKeyCreate, ApiKeyResponse } from '$lib/types/api-key.type';
|
||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||
import APIService from './api-service';
|
||||
|
||||
export default class ApiKeyService extends APIService {
|
||||
async list(options?: SearchPaginationSortRequest) {
|
||||
const res = await this.api.get('/api-keys', {
|
||||
params: options
|
||||
});
|
||||
return res.data as Paginated<ApiKey>;
|
||||
}
|
||||
|
||||
async create(data: ApiKeyCreate): Promise<ApiKeyResponse> {
|
||||
const res = await this.api.post('/api-keys', data);
|
||||
return res.data as ApiKeyResponse;
|
||||
}
|
||||
|
||||
async revoke(id: string): Promise<void> {
|
||||
await this.api.delete(`/api-keys/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AuthorizeResponse,
|
||||
OidcClient,
|
||||
OidcClientCreate,
|
||||
OidcClientMetaData,
|
||||
OidcClientWithAllowedUserGroups
|
||||
} from '$lib/types/oidc.type';
|
||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||
@@ -56,6 +57,10 @@ class OidcService extends APIService {
|
||||
return (await this.api.get(`/oidc/clients/${id}`)).data as OidcClientWithAllowedUserGroups;
|
||||
}
|
||||
|
||||
async getClientMetaData(id: string) {
|
||||
return (await this.api.get(`/oidc/clients/${id}/meta`)).data as OidcClientMetaData;
|
||||
}
|
||||
|
||||
async updateClient(id: string, client: OidcClientCreate) {
|
||||
return (await this.api.put(`/oidc/clients/${id}`, client)).data as OidcClient;
|
||||
}
|
||||
|
||||
19
frontend/src/lib/types/api-key.type.ts
Normal file
19
frontend/src/lib/types/api-key.type.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type ApiKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
expiresAt: string;
|
||||
lastUsedAt?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ApiKeyCreate = {
|
||||
name: string;
|
||||
description?: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
export type ApiKeyResponse = {
|
||||
apiKey: ApiKey;
|
||||
token: string;
|
||||
};
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { UserGroup } from './user-group.type';
|
||||
|
||||
export type OidcClient = {
|
||||
export type OidcClientMetaData = {
|
||||
id: string;
|
||||
name: string;
|
||||
logoURL: string;
|
||||
hasLogo: boolean;
|
||||
};
|
||||
|
||||
export type OidcClient = OidcClientMetaData & {
|
||||
callbackURLs: [string, ...string[]];
|
||||
logoutCallbackURLs: string[];
|
||||
hasLogo: boolean;
|
||||
isPublic: boolean;
|
||||
pkceEnabled: boolean;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
const clientId = url.searchParams.get('client_id');
|
||||
const oidcService = new OidcService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
||||
|
||||
const client = await oidcService.getClient(clientId!);
|
||||
const client = await oidcService.getClientMetaData(clientId!);
|
||||
|
||||
return {
|
||||
scope: url.searchParams.get('scope')!,
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
{ 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' }
|
||||
];
|
||||
}
|
||||
|
||||
16
frontend/src/routes/settings/admin/api-keys/+page.server.ts
Normal file
16
frontend/src/routes/settings/admin/api-keys/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
|
||||
import ApiKeyService from '$lib/services/api-key-service';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const apiKeyService = new ApiKeyService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
||||
|
||||
const apiKeys = await apiKeyService.list({
|
||||
sort: {
|
||||
column: 'lastUsedAt',
|
||||
direction: 'desc' as const
|
||||
}
|
||||
});
|
||||
|
||||
return apiKeys;
|
||||
};
|
||||
89
frontend/src/routes/settings/admin/api-keys/+page.svelte
Normal file
89
frontend/src/routes/settings/admin/api-keys/+page.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import ApiKeyService from '$lib/services/api-key-service';
|
||||
import type { ApiKey, ApiKeyCreate, ApiKeyResponse } from '$lib/types/api-key.type';
|
||||
import type { Paginated } from '$lib/types/pagination.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideMinus } from 'lucide-svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import ApiKeyDialog from './api-key-dialog.svelte';
|
||||
import ApiKeyForm from './api-key-form.svelte';
|
||||
import ApiKeyList from './api-key-list.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let apiKeys = $state<Paginated<ApiKey>>(data);
|
||||
|
||||
const apiKeyService = new ApiKeyService();
|
||||
let expandAddApiKey = $state(false);
|
||||
let apiKeyResponse = $state<ApiKeyResponse | null>(null);
|
||||
|
||||
async function createApiKey(apiKeyData: ApiKeyCreate) {
|
||||
try {
|
||||
const response = await apiKeyService.create(apiKeyData);
|
||||
apiKeyResponse = response;
|
||||
|
||||
// After creation, reload the list of API keys
|
||||
const updatedKeys = await apiKeyService.list();
|
||||
apiKeys = updatedKeys;
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDialogClose(open: boolean) {
|
||||
if (!open) {
|
||||
apiKeyResponse = null;
|
||||
// Refresh the list when dialog closes
|
||||
apiKeyService
|
||||
.list()
|
||||
.then((keys) => {
|
||||
apiKeys = keys;
|
||||
})
|
||||
.catch(axiosErrorToast);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>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>
|
||||
</div>
|
||||
{#if !expandAddApiKey}
|
||||
<Button on:click={() => (expandAddApiKey = true)}>Add API Key</Button>
|
||||
{:else}
|
||||
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddApiKey = false)}>
|
||||
<LucideMinus class="h-5 w-5" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Header>
|
||||
{#if expandAddApiKey}
|
||||
<div transition:slide>
|
||||
<Card.Content>
|
||||
<ApiKeyForm callback={createApiKey} />
|
||||
</Card.Content>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class="mt-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Manage API Keys</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<ApiKeyList {apiKeys} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<ApiKeyDialog bind:apiKeyResponse onOpenChange={handleDialogClose} />
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
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 type { ApiKeyResponse } from '$lib/types/api-key.type';
|
||||
|
||||
let {
|
||||
apiKeyResponse = $bindable(),
|
||||
onOpenChange
|
||||
}: {
|
||||
apiKeyResponse: ApiKeyResponse | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={!!apiKeyResponse} {onOpenChange}>
|
||||
<Dialog.Content class="max-w-md" closeButton={false}>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>API Key Created</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
For security reasons, this key will only be shown once. Please store it securely.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
{#if apiKeyResponse}
|
||||
<div>
|
||||
<div class="mb-2 font-medium">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>
|
||||
<p class="text-muted-foreground">{apiKeyResponse.apiKey.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mb-2 mt-4 font-medium">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>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<Dialog.Footer class="mt-3">
|
||||
<Button variant="default" on:click={() => onOpenChange(false)}>Close</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import type { ApiKeyCreate } from '$lib/types/api-key.type';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { z } from 'zod';
|
||||
|
||||
let {
|
||||
callback
|
||||
}: {
|
||||
callback: (apiKey: ApiKeyCreate) => Promise<boolean>;
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
// Set default expiration to 30 days from now
|
||||
const defaultExpiry = new Date();
|
||||
defaultExpiry.setDate(defaultExpiry.getDate() + 30);
|
||||
|
||||
const apiKey = {
|
||||
name: '',
|
||||
description: '',
|
||||
expiresAt: defaultExpiry
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(3, 'Name must be at least 3 characters')
|
||||
.max(50, 'Name cannot exceed 50 characters'),
|
||||
description: z.string().default(''),
|
||||
expiresAt: z.date().min(new Date(), 'Expiration date must be in the future')
|
||||
});
|
||||
|
||||
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, apiKey);
|
||||
|
||||
async function onSubmit() {
|
||||
const data = form.validate();
|
||||
if (!data) return;
|
||||
|
||||
const apiKeyData: ApiKeyCreate = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
expiresAt: data.expiresAt
|
||||
};
|
||||
|
||||
isLoading = true;
|
||||
const success = await callback(apiKeyData);
|
||||
if (success) form.reset();
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={onSubmit}>
|
||||
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
|
||||
<FormInput
|
||||
label="Name"
|
||||
bind:input={$inputs.name}
|
||||
description="Name to identify this API key."
|
||||
/>
|
||||
<FormInput
|
||||
label="Expires At"
|
||||
type="date"
|
||||
description="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."
|
||||
bind:input={$inputs.description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button {isLoading} type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
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';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideBan } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let {
|
||||
apiKeys: initialApiKeys
|
||||
}: {
|
||||
apiKeys: Paginated<ApiKey>;
|
||||
} = $props();
|
||||
|
||||
let apiKeys = $state<Paginated<ApiKey>>(initialApiKeys);
|
||||
let requestOptions: SearchPaginationSortRequest | undefined = $state({
|
||||
sort: {
|
||||
column: 'lastUsedAt',
|
||||
direction: 'desc'
|
||||
}
|
||||
});
|
||||
const apiKeyService = new ApiKeyService();
|
||||
|
||||
// Update the local state whenever the prop changes
|
||||
$effect(() => {
|
||||
apiKeys = initialApiKeys;
|
||||
});
|
||||
|
||||
function formatDate(dateStr: string | undefined) {
|
||||
if (!dateStr) return '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.`,
|
||||
confirm: {
|
||||
label: 'Revoke',
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await apiKeyService.revoke(apiKey.id);
|
||||
apiKeys = await apiKeyService.list(requestOptions);
|
||||
toast.success('API key revoked successfully');
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Initial load uses the server-side data
|
||||
apiKeys = initialApiKeys;
|
||||
});
|
||||
</script>
|
||||
|
||||
<AdvancedTable
|
||||
items={apiKeys}
|
||||
{requestOptions}
|
||||
onRefresh={async (o) => (apiKeys = await apiKeyService.list(o))}
|
||||
withoutSearch
|
||||
defaultSort={{ column: 'lastUsedAt', direction: 'desc' }}
|
||||
columns={[
|
||||
{ label: 'Name', sortColumn: 'name' },
|
||||
{ label: 'Description' },
|
||||
{ label: 'Expires At', sortColumn: 'expiresAt' },
|
||||
{ label: 'Last Used', sortColumn: 'lastUsedAt' },
|
||||
{ label: 'Actions', hidden: true }
|
||||
]}
|
||||
>
|
||||
{#snippet rows({ item })}
|
||||
<Table.Cell>{item.name}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{item.description || '-'}</Table.Cell>
|
||||
<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"
|
||||
><LucideBan class="h-3 w-3 text-red-500" /></Button
|
||||
>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</AdvancedTable>
|
||||
70
frontend/tests/api-key.spec.ts
Normal file
70
frontend/tests/api-key.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// frontend/tests/api-key.spec.ts
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { apiKeys } from './data';
|
||||
import { cleanupBackend } from './utils/cleanup.util';
|
||||
|
||||
test.describe('API Key Management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await cleanupBackend()
|
||||
await page.goto('/settings/admin/api-keys');
|
||||
});
|
||||
|
||||
test('Create new API key', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Add API Key' }).click();
|
||||
|
||||
// Fill out the API key form
|
||||
const name = 'New Test API Key';
|
||||
await page.getByLabel('Name').fill(name);
|
||||
await page.getByLabel('Description').fill('Created by automated test');
|
||||
|
||||
// Choose the date
|
||||
const currentDate = new Date();
|
||||
await page.getByLabel('Expires At').click();
|
||||
await page.getByLabel('Select year').click();
|
||||
// Select the next year
|
||||
await page.getByText((currentDate.getFullYear() + 1).toString()).click();
|
||||
// Select the first day of the month
|
||||
await page
|
||||
.getByRole('button', { name: /([A-Z][a-z]+), ([A-Z][a-z]+) 1, (\d{4})/ })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Verify the success dialog appears
|
||||
await expect(page.getByRole('heading', { name: 'API Key Created' })).toBeVisible();
|
||||
|
||||
// Verify the key details are shown
|
||||
await expect(page.getByRole('cell', { name })).toBeVisible();
|
||||
|
||||
// Verify the token is displayed (should be 32 characters)
|
||||
const token = await page.locator('.font-mono').textContent();
|
||||
expect(token?.length).toBe(32);
|
||||
|
||||
// Close the dialog
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
await page.reload();
|
||||
|
||||
// Verify the key appears in the list
|
||||
await expect(page.getByRole('cell', { name }).first()).toContainText(name);
|
||||
});
|
||||
|
||||
test('Revoke API key', async ({ page }) => {
|
||||
const apiKey = apiKeys[0];
|
||||
|
||||
await page
|
||||
.getByRole('row', { name: apiKey.name })
|
||||
.getByRole('button', { name: 'Revoke' })
|
||||
.click();
|
||||
|
||||
await page.getByText('Revoke', { exact: true }).click();
|
||||
|
||||
// Verify success message
|
||||
await expect(page.getByRole('status')).toHaveText('API key revoked successfully');
|
||||
|
||||
// Verify key is no longer in the list
|
||||
await expect(page.getByRole('cell', { name: apiKey.name })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -61,3 +61,11 @@ export const oneTimeAccessTokens = [
|
||||
{ token: 'HPe6k6uiDRRVuAQV', expired: false },
|
||||
{ token: 'YCGDtftvsvYWiXd0', expired: true }
|
||||
];
|
||||
|
||||
export const apiKeys = [
|
||||
{
|
||||
id: '5f1fa856-c164-4295-961e-175a0d22d725',
|
||||
key: '6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20',
|
||||
name: 'Test API Key'
|
||||
}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user