formatted, added team index, create, show
CI/CD / test_and_check_app (push) Failing after 48s
CI/CD / Publish immutable image (push) Skipped

This commit is contained in:
2026-09-19 01:09:55 +02:00
parent 9a025d6a80
commit b79d93fede
76 changed files with 3290 additions and 3146 deletions
+14 -14
View File
@@ -4,27 +4,27 @@ import { computed } from 'vue';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
type Props = {
errors: string[];
title?: string;
errors: string[];
title?: string;
};
const props = withDefaults(defineProps<Props>(), {
title: 'Something went wrong.',
title: 'Something went wrong.',
});
const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
</script>
<template>
<Alert variant="destructive">
<AlertCircle class="size-4" />
<AlertTitle>{{ title }}</AlertTitle>
<AlertDescription>
<ul class="list-inside list-disc text-sm">
<li v-for="(error, index) in uniqueErrors" :key="index">
{{ error }}
</li>
</ul>
</AlertDescription>
</Alert>
<Alert variant="destructive">
<AlertCircle class="size-4" />
<AlertTitle>{{ title }}</AlertTitle>
<AlertDescription>
<ul class="list-inside list-disc text-sm">
<li v-for="(error, index) in uniqueErrors" :key="index">
{{ error }}
</li>
</ul>
</AlertDescription>
</Alert>
</template>
+13 -13
View File
@@ -4,25 +4,25 @@ import { SidebarInset } from '@/components/ui/sidebar';
import type { AppVariant } from '@/types';
type Props = {
variant?: AppVariant;
class?: string;
variant?: AppVariant;
class?: string;
};
const props = withDefaults(defineProps<Props>(), {
variant: 'sidebar',
variant: 'sidebar',
});
const className = computed(() => props.class);
</script>
<template>
<SidebarInset v-if="props.variant === 'sidebar'" :class="className">
<slot />
</SidebarInset>
<main
v-else
class="mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl"
:class="className"
>
<slot />
</main>
<SidebarInset v-if="props.variant === 'sidebar'" :class="className">
<slot />
</SidebarInset>
<main
v-else
class="mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl"
:class="className"
>
<slot />
</main>
</template>
+204 -229
View File
@@ -8,28 +8,28 @@ import Breadcrumbs from '@/components/Breadcrumbs.vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuList,
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuItem,
NavigationMenuList,
navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import UserMenuContent from '@/components/UserMenuContent.vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
@@ -39,11 +39,11 @@ import { dashboard } from '@/routes';
import type { BreadcrumbItem, NavItem } from '@/types';
type Props = {
breadcrumbs?: BreadcrumbItem[];
breadcrumbs?: BreadcrumbItem[];
};
const props = withDefaults(defineProps<Props>(), {
breadcrumbs: () => [],
breadcrumbs: () => [],
});
const page = usePage();
@@ -51,233 +51,208 @@ const auth = computed(() => page.props.auth);
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
const activeItemStyles =
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
const mainNavItems: NavItem[] = [
{
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
{
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
];
const rightNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
icon: Folder,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen,
},
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
icon: Folder,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen,
},
];
</script>
<template>
<div>
<div class="border-sidebar-border/80 border-b">
<div class="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
<!-- Mobile Menu -->
<div class="lg:hidden">
<Sheet>
<SheetTrigger :as-child="true">
<Button
variant="ghost"
size="icon"
class="mr-2 h-9 w-9"
>
<Menu class="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" class="w-[300px] p-6">
<SheetTitle class="sr-only"
>Navigation menu</SheetTitle
>
<SheetHeader class="flex justify-start text-left">
<AppLogoIcon
class="size-6 fill-current text-black dark:text-white"
/>
</SheetHeader>
<div
class="flex h-full flex-1 flex-col justify-between space-y-4 py-6"
>
<nav class="-mx-3 space-y-1">
<Link
v-for="item in mainNavItems"
:key="item.title"
:href="item.href"
class="hover:bg-accent flex items-center gap-x-3 rounded-lg px-3 py-2 text-sm font-medium"
:class="
whenCurrentUrl(
item.href,
activeItemStyles,
)
"
>
<component
v-if="item.icon"
:is="item.icon"
class="h-5 w-5"
/>
{{ item.title }}
</Link>
</nav>
<div class="flex flex-col space-y-4">
<a
v-for="item in rightNavItems"
:key="item.title"
:href="toUrl(item.href)"
target="_blank"
rel="noopener noreferrer"
class="flex items-center space-x-2 text-sm font-medium"
>
<component
v-if="item.icon"
:is="item.icon"
class="h-5 w-5"
/>
<span>{{ item.title }}</span>
</a>
</div>
</div>
</SheetContent>
</Sheet>
<div>
<div class="border-sidebar-border/80 border-b">
<div class="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
<!-- Mobile Menu -->
<div class="lg:hidden">
<Sheet>
<SheetTrigger :as-child="true">
<Button variant="ghost" size="icon" class="mr-2 h-9 w-9">
<Menu class="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" class="w-[300px] p-6">
<SheetTitle class="sr-only">Navigation menu</SheetTitle>
<SheetHeader class="flex justify-start text-left">
<AppLogoIcon
class="size-6 fill-current text-black dark:text-white"
/>
</SheetHeader>
<div
class="flex h-full flex-1 flex-col justify-between space-y-4 py-6"
>
<nav class="-mx-3 space-y-1">
<Link
v-for="item in mainNavItems"
:key="item.title"
:href="item.href"
class="hover:bg-accent flex items-center gap-x-3 rounded-lg px-3 py-2 text-sm font-medium"
:class="whenCurrentUrl(item.href, activeItemStyles)"
>
<component
v-if="item.icon"
:is="item.icon"
class="h-5 w-5"
/>
{{ item.title }}
</Link>
</nav>
<div class="flex flex-col space-y-4">
<a
v-for="item in rightNavItems"
:key="item.title"
:href="toUrl(item.href)"
target="_blank"
rel="noopener noreferrer"
class="flex items-center space-x-2 text-sm font-medium"
>
<component
v-if="item.icon"
:is="item.icon"
class="h-5 w-5"
/>
<span>{{ item.title }}</span>
</a>
</div>
</div>
</SheetContent>
</Sheet>
</div>
<Link :href="dashboard()" class="flex items-center gap-x-2">
<AppLogo />
<Link :href="dashboard()" class="flex items-center gap-x-2">
<AppLogo />
</Link>
<!-- Desktop Menu -->
<div class="hidden h-full lg:flex lg:flex-1">
<NavigationMenu class="ml-10 flex h-full items-stretch">
<NavigationMenuList class="flex h-full items-stretch space-x-2">
<NavigationMenuItem
v-for="(item, index) in mainNavItems"
:key="index"
class="relative flex h-full items-center"
>
<Link
:class="[
navigationMenuTriggerStyle(),
whenCurrentUrl(item.href, activeItemStyles),
'h-9 cursor-pointer px-3',
]"
:href="item.href"
>
<component
v-if="item.icon"
:is="item.icon"
class="mr-2 h-4 w-4"
/>
{{ item.title }}
</Link>
<!-- Desktop Menu -->
<div class="hidden h-full lg:flex lg:flex-1">
<NavigationMenu class="ml-10 flex h-full items-stretch">
<NavigationMenuList
class="flex h-full items-stretch space-x-2"
>
<NavigationMenuItem
v-for="(item, index) in mainNavItems"
:key="index"
class="relative flex h-full items-center"
>
<Link
:class="[
navigationMenuTriggerStyle(),
whenCurrentUrl(
item.href,
activeItemStyles,
),
'h-9 cursor-pointer px-3',
]"
:href="item.href"
>
<component
v-if="item.icon"
:is="item.icon"
class="mr-2 h-4 w-4"
/>
{{ item.title }}
</Link>
<div
v-if="isCurrentUrl(item.href)"
class="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white"
></div>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
<div class="ml-auto flex items-center space-x-2">
<div class="relative flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
class="group h-9 w-9 cursor-pointer"
>
<Search
class="size-5 opacity-80 group-hover:opacity-100"
/>
</Button>
<div class="hidden space-x-1 lg:flex">
<template
v-for="item in rightNavItems"
:key="item.title"
>
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="icon"
as-child
class="group h-9 w-9 cursor-pointer"
>
<a
:href="toUrl(item.href)"
target="_blank"
rel="noopener noreferrer"
>
<span class="sr-only">{{
item.title
}}</span>
<component
:is="item.icon"
class="size-5 opacity-80 group-hover:opacity-100"
/>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ item.title }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger :as-child="true">
<Button
variant="ghost"
size="icon"
class="focus-within:ring-primary relative size-10 w-auto rounded-full p-1 focus-within:ring-2"
>
<Avatar
class="size-8 overflow-hidden rounded-full"
>
<AvatarImage
v-if="auth.user.avatar"
:src="auth.user.avatar"
:alt="auth.user.name"
/>
<AvatarFallback
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
>
{{ getInitials(auth.user?.name) }}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56">
<UserMenuContent :user="auth.user" />
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div
v-if="isCurrentUrl(item.href)"
class="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white"
></div>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
<div
v-if="props.breadcrumbs.length > 1"
class="border-sidebar-border/70 flex w-full border-b"
>
<div
class="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl"
<div class="ml-auto flex items-center space-x-2">
<div class="relative flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
class="group h-9 w-9 cursor-pointer"
>
<Breadcrumbs :breadcrumbs="breadcrumbs" />
<Search class="size-5 opacity-80 group-hover:opacity-100" />
</Button>
<div class="hidden space-x-1 lg:flex">
<template v-for="item in rightNavItems" :key="item.title">
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="icon"
as-child
class="group h-9 w-9 cursor-pointer"
>
<a
:href="toUrl(item.href)"
target="_blank"
rel="noopener noreferrer"
>
<span class="sr-only">{{ item.title }}</span>
<component
:is="item.icon"
class="size-5 opacity-80 group-hover:opacity-100"
/>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ item.title }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger :as-child="true">
<Button
variant="ghost"
size="icon"
class="focus-within:ring-primary relative size-10 w-auto rounded-full p-1 focus-within:ring-2"
>
<Avatar class="size-8 overflow-hidden rounded-full">
<AvatarImage
v-if="auth.user.avatar"
:src="auth.user.avatar"
:alt="auth.user.name"
/>
<AvatarFallback
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
>
{{ getInitials(auth.user?.name) }}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56">
<UserMenuContent :user="auth.user" />
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<div
v-if="props.breadcrumbs.length > 1"
class="border-sidebar-border/70 flex w-full border-b"
>
<div
class="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl"
>
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</div>
</div>
</div>
</template>
+8 -10
View File
@@ -6,14 +6,12 @@ const name = usePage().props.name;
</script>
<template>
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
>
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate leading-tight font-semibold">{{
name
}}</span>
</div>
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
>
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate leading-tight font-semibold">{{ name }}</span>
</div>
</template>
+15 -15
View File
@@ -2,28 +2,28 @@
import type { HTMLAttributes } from 'vue';
defineOptions({
inheritAttrs: false,
inheritAttrs: false,
});
type Props = {
className?: HTMLAttributes['class'];
className?: HTMLAttributes['class'];
};
defineProps<Props>();
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 40 42"
:class="className"
v-bind="$attrs"
>
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
/>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 40 42"
:class="className"
v-bind="$attrs"
>
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
/>
</svg>
</template>
+8 -8
View File
@@ -4,21 +4,21 @@ import { SidebarProvider } from '@/components/ui/sidebar';
import type { AppVariant } from '@/types';
type Props = {
variant?: AppVariant;
variant?: AppVariant;
};
withDefaults(defineProps<Props>(), {
variant: 'sidebar',
variant: 'sidebar',
});
const isOpen = usePage().props.sidebarOpen;
</script>
<template>
<div v-if="variant === 'header'" class="flex min-h-screen w-full flex-col">
<slot />
</div>
<SidebarProvider v-else :default-open="isOpen">
<slot />
</SidebarProvider>
<div v-if="variant === 'header'" class="flex min-h-screen w-full flex-col">
<slot />
</div>
<SidebarProvider v-else :default-open="isOpen">
<slot />
</SidebarProvider>
</template>
+35 -36
View File
@@ -6,52 +6,51 @@ import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue';
import NavUser from '@/components/NavUser.vue';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import type { NavItem } from '@/types';
import teams from "@/routes/teams";
import teams from '@/routes/teams';
const mainNavItems: NavItem[] = [
{
title: 'Meine Teams',
href: teams.index(),
icon: UserGroup,
},
{
title: 'Meine Teams',
href: teams.index(),
icon: UserGroup,
},
];
const footerNavItems: NavItem[] = [
];
const footerNavItems: NavItem[] = [];
</script>
<template>
<Sidebar collapsible="icon" variant="inset">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" as-child>
<Link :href="dashboard()">
<AppLogo />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<Sidebar collapsible="icon" variant="inset">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" as-child>
<Link :href="dashboard()">
<AppLogo />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain :items="mainNavItems" />
</SidebarContent>
<SidebarContent>
<NavMain :items="mainNavItems" />
</SidebarContent>
<SidebarFooter>
<NavFooter :items="footerNavItems" />
<NavUser />
</SidebarFooter>
</Sidebar>
<slot />
<SidebarFooter>
<NavFooter :items="footerNavItems" />
<NavUser />
</SidebarFooter>
</Sidebar>
<slot />
</template>
+16 -16
View File
@@ -4,24 +4,24 @@ import { SidebarTrigger } from '@/components/ui/sidebar';
import type { BreadcrumbItem } from '@/types';
withDefaults(
defineProps<{
breadcrumbs?: BreadcrumbItem[];
}>(),
{
breadcrumbs: () => [],
},
defineProps<{
breadcrumbs?: BreadcrumbItem[];
}>(),
{
breadcrumbs: () => [],
},
);
</script>
<template>
<header
class="border-sidebar-border/70 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4"
>
<div class="flex items-center gap-2">
<SidebarTrigger class="-ml-1" />
<template v-if="breadcrumbs && breadcrumbs.length > 0">
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</template>
</div>
</header>
<header
class="border-sidebar-border/70 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4"
>
<div class="flex items-center gap-2">
<SidebarTrigger class="-ml-1" />
<template v-if="breadcrumbs && breadcrumbs.length > 0">
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</template>
</div>
</header>
</template>
+20 -20
View File
@@ -5,29 +5,29 @@ import { useAppearance } from '@/composables/useAppearance';
const { appearance, updateAppearance } = useAppearance();
const tabs = [
{ value: 'light', Icon: Sun, label: 'Light' },
{ value: 'dark', Icon: Moon, label: 'Dark' },
{ value: 'system', Icon: Monitor, label: 'System' },
{ value: 'light', Icon: Sun, label: 'Light' },
{ value: 'dark', Icon: Moon, label: 'Dark' },
{ value: 'system', Icon: Monitor, label: 'System' },
] as const;
</script>
<template>
<div
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
<div
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
>
<button
v-for="{ value, Icon, label } in tabs"
:key="value"
@click="updateAppearance(value)"
:class="[
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
appearance === value
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
]"
>
<button
v-for="{ value, Icon, label } in tabs"
:key="value"
@click="updateAppearance(value)"
:class="[
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
appearance === value
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
]"
>
<component :is="Icon" class="-ml-1 h-4 w-4" />
<span class="ml-1.5 text-sm">{{ label }}</span>
</button>
</div>
<component :is="Icon" class="-ml-1 h-4 w-4" />
<span class="ml-1.5 text-sm">{{ label }}</span>
</button>
</div>
</template>
+24 -24
View File
@@ -1,38 +1,38 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
type Props = {
breadcrumbs: BreadcrumbItemType[];
breadcrumbs: BreadcrumbItemType[];
};
defineProps<Props>();
</script>
<template>
<Breadcrumb>
<BreadcrumbList>
<template v-for="(item, index) in breadcrumbs" :key="index">
<BreadcrumbItem>
<template v-if="index === breadcrumbs.length - 1">
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage>
</template>
<template v-else>
<BreadcrumbLink as-child>
<Link :href="item.href">{{ item.title }}</Link>
</BreadcrumbLink>
</template>
</BreadcrumbItem>
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" />
</template>
</BreadcrumbList>
</Breadcrumb>
<Breadcrumb>
<BreadcrumbList>
<template v-for="(item, index) in breadcrumbs" :key="index">
<BreadcrumbItem>
<template v-if="index === breadcrumbs.length - 1">
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage>
</template>
<template v-else>
<BreadcrumbLink as-child>
<Link :href="item.href">{{ item.title }}</Link>
</BreadcrumbLink>
</template>
</BreadcrumbItem>
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" />
</template>
</BreadcrumbList>
</Breadcrumb>
</template>
+87 -92
View File
@@ -7,14 +7,14 @@ import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
@@ -22,92 +22,87 @@ const passwordInput = useTemplateRef('passwordInput');
</script>
<template>
<div class="space-y-6">
<Heading
variant="small"
title="Delete account"
description="Delete your account and all of its resources"
/>
<div
class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"
>
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
<p class="font-medium">Warning</p>
<p class="text-sm">
Please proceed with caution, this cannot be undone.
</p>
<div class="space-y-6">
<Heading
variant="small"
title="Delete account"
description="Delete your account and all of its resources"
/>
<div
class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"
>
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
<p class="font-medium">Warning</p>
<p class="text-sm">
Please proceed with caution, this cannot be undone.
</p>
</div>
<Dialog>
<DialogTrigger as-child>
<Button variant="destructive" data-test="delete-user-button"
>Delete account</Button
>
</DialogTrigger>
<DialogContent>
<Form
v-bind="ProfileController.destroy.form()"
reset-on-success
@error="() => passwordInput?.focus()"
:options="{
preserveScroll: true,
}"
class="space-y-6"
v-slot="{ errors, processing, reset, clearErrors }"
>
<DialogHeader class="space-y-3">
<DialogTitle
>Are you sure you want to delete your account?</DialogTitle
>
<DialogDescription>
Once your account is deleted, all of its resources and data will
also be permanently deleted. Please enter your password to
confirm you would like to permanently delete your account.
</DialogDescription>
</DialogHeader>
<div class="grid gap-2">
<Label for="password" class="sr-only">Password</Label>
<PasswordInput
id="password"
name="password"
ref="passwordInput"
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<Dialog>
<DialogTrigger as-child>
<Button variant="destructive" data-test="delete-user-button"
>Delete account</Button
>
</DialogTrigger>
<DialogContent>
<Form
v-bind="ProfileController.destroy.form()"
reset-on-success
@error="() => passwordInput?.focus()"
:options="{
preserveScroll: true,
}"
class="space-y-6"
v-slot="{ errors, processing, reset, clearErrors }"
>
<DialogHeader class="space-y-3">
<DialogTitle
>Are you sure you want to delete your
account?</DialogTitle
>
<DialogDescription>
Once your account is deleted, all of its
resources and data will also be permanently
deleted. Please enter your password to confirm
you would like to permanently delete your
account.
</DialogDescription>
</DialogHeader>
<div class="grid gap-2">
<Label for="password" class="sr-only"
>Password</Label
>
<PasswordInput
id="password"
name="password"
ref="passwordInput"
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<DialogFooter class="gap-2">
<DialogClose as-child>
<Button
variant="secondary"
@click="
() => {
clearErrors();
reset();
}
"
>
Cancel
</Button>
</DialogClose>
<DialogFooter class="gap-2">
<DialogClose as-child>
<Button
variant="secondary"
@click="
() => {
clearErrors();
reset();
}
"
>
Cancel
</Button>
</DialogClose>
<Button
type="submit"
variant="destructive"
:disabled="processing"
data-test="confirm-delete-user-button"
>
Delete account
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
<Button
type="submit"
variant="destructive"
:disabled="processing"
data-test="confirm-delete-user-button"
>
Delete account
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
</template>
+18 -18
View File
@@ -1,28 +1,28 @@
<script setup lang="ts">
type Props = {
title: string;
description?: string;
variant?: 'default' | 'small';
title: string;
description?: string;
variant?: 'default' | 'small';
};
withDefaults(defineProps<Props>(), {
variant: 'default',
variant: 'default',
});
</script>
<template>
<header :class="variant === 'small' ? '' : 'mb-8 space-y-0.5'">
<h2
:class="
variant === 'small'
? 'mb-0.5 text-base font-medium'
: 'text-xl font-semibold tracking-tight'
"
>
{{ title }}
</h2>
<p v-if="description" class="text-muted-foreground text-sm">
{{ description }}
</p>
</header>
<header :class="variant === 'small' ? '' : 'mb-8 space-y-0.5'">
<h2
:class="
variant === 'small'
? 'mb-0.5 text-base font-medium'
: 'text-xl font-semibold tracking-tight'
"
>
{{ title }}
</h2>
<p v-if="description" class="text-muted-foreground text-sm">
{{ description }}
</p>
</header>
</template>
+6 -6
View File
@@ -1,13 +1,13 @@
<script setup lang="ts">
defineProps<{
message?: string;
message?: string;
}>();
</script>
<template>
<div v-show="message">
<p class="text-sm text-red-600 dark:text-red-500">
{{ message }}
</p>
</div>
<div v-show="message">
<p class="text-sm text-red-600 dark:text-red-500">
{{ message }}
</p>
</div>
</template>
+37 -37
View File
@@ -8,58 +8,58 @@ import PasskeyRegister from '@/components/PasskeyRegister.vue';
import { destroy } from '@/actions/Laravel/Passkeys/Http/Controllers/PasskeyRegistrationController';
export type Props = {
canManagePasskeys?: boolean;
passkeys?: Passkey[];
canManagePasskeys?: boolean;
passkeys?: Passkey[];
};
withDefaults(defineProps<Props>(), {
canManagePasskeys: false,
passkeys: () => [],
canManagePasskeys: false,
passkeys: () => [],
});
const handleDelete = (id: number, onError: () => void) => {
router.delete(destroy.url(id), {
preserveScroll: true,
onError,
});
router.delete(destroy.url(id), {
preserveScroll: true,
onError,
});
};
const handleRegisterSuccess = () => {
router.reload();
router.reload();
};
</script>
<template>
<div v-if="canManagePasskeys" class="space-y-6">
<Heading
variant="small"
title="Passkeys"
description="Manage your passkeys for passwordless sign-in"
<div v-if="canManagePasskeys" class="space-y-6">
<Heading
variant="small"
title="Passkeys"
description="Manage your passkeys for passwordless sign-in"
/>
<div class="border-border overflow-hidden rounded-lg border">
<template v-if="passkeys.length">
<PasskeyItem
v-for="passkey in passkeys"
:key="passkey.id"
:passkey="passkey"
@remove="handleDelete"
/>
</template>
<div class="border-border overflow-hidden rounded-lg border">
<template v-if="passkeys.length">
<PasskeyItem
v-for="passkey in passkeys"
:key="passkey.id"
:passkey="passkey"
@remove="handleDelete"
/>
</template>
<div v-else class="p-8 text-center">
<div
class="bg-muted mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl"
>
<KeyRound class="text-muted-foreground h-7 w-7" />
</div>
<p class="font-medium">No passkeys yet</p>
<p class="text-muted-foreground mt-1 text-sm">
Add a passkey to sign in without a password
</p>
</div>
<div v-else class="p-8 text-center">
<div
class="bg-muted mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl"
>
<KeyRound class="text-muted-foreground h-7 w-7" />
</div>
<PasskeyRegister @success="handleRegisterSuccess" />
<p class="font-medium">No passkeys yet</p>
<p class="text-muted-foreground mt-1 text-sm">
Add a passkey to sign in without a password
</p>
</div>
</div>
<PasskeyRegister @success="handleRegisterSuccess" />
</div>
</template>
+58 -65
View File
@@ -10,15 +10,15 @@ import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { disable, enable } from '@/routes/two-factor';
export type Props = {
canManageTwoFactor?: boolean;
requiresConfirmation?: boolean;
twoFactorEnabled?: boolean;
canManageTwoFactor?: boolean;
requiresConfirmation?: boolean;
twoFactorEnabled?: boolean;
};
withDefaults(defineProps<Props>(), {
canManageTwoFactor: false,
requiresConfirmation: false,
twoFactorEnabled: false,
canManageTwoFactor: false,
requiresConfirmation: false,
twoFactorEnabled: false,
});
const { hasSetupData, clearTwoFactorAuthData } = useTwoFactorAuth();
@@ -28,66 +28,59 @@ onUnmounted(() => clearTwoFactorAuthData());
</script>
<template>
<div v-if="canManageTwoFactor" class="space-y-6">
<Heading
variant="small"
title="Two-factor authentication"
description="Manage your two-factor authentication settings"
/>
<div v-if="canManageTwoFactor" class="space-y-6">
<Heading
variant="small"
title="Two-factor authentication"
description="Manage your two-factor authentication settings"
/>
<div
v-if="!twoFactorEnabled"
class="flex flex-col items-start justify-start space-y-4"
<div
v-if="!twoFactorEnabled"
class="flex flex-col items-start justify-start space-y-4"
>
<p class="text-muted-foreground text-sm">
When you enable two-factor authentication, you will be prompted for a
secure pin during login. This pin can be retrieved from a TOTP-supported
application on your phone.
</p>
<div>
<Button v-if="hasSetupData" @click="showSetupModal = true">
<ShieldCheck />Continue setup
</Button>
<Form
v-else
v-bind="enable.form()"
@success="showSetupModal = true"
#default="{ processing }"
>
<p class="text-muted-foreground text-sm">
When you enable two-factor authentication, you will be prompted
for a secure pin during login. This pin can be retrieved from a
TOTP-supported application on your phone.
</p>
<div>
<Button v-if="hasSetupData" @click="showSetupModal = true">
<ShieldCheck />Continue setup
</Button>
<Form
v-else
v-bind="enable.form()"
@success="showSetupModal = true"
#default="{ processing }"
>
<Button type="submit" :disabled="processing">
Enable 2FA
</Button>
</Form>
</div>
</div>
<div v-else class="flex flex-col items-start justify-start space-y-4">
<p class="text-muted-foreground text-sm">
You will be prompted for a secure, random pin during login,
which you can retrieve from the TOTP-supported application on
your phone.
</p>
<div class="relative inline">
<Form v-bind="disable.form()" #default="{ processing }">
<Button
variant="destructive"
type="submit"
:disabled="processing"
>
Disable 2FA
</Button>
</Form>
</div>
<TwoFactorRecoveryCodes />
</div>
<TwoFactorSetupModal
v-model:isOpen="showSetupModal"
:requiresConfirmation="requiresConfirmation"
:twoFactorEnabled="twoFactorEnabled"
/>
<Button type="submit" :disabled="processing"> Enable 2FA </Button>
</Form>
</div>
</div>
<div v-else class="flex flex-col items-start justify-start space-y-4">
<p class="text-muted-foreground text-sm">
You will be prompted for a secure, random pin during login, which you
can retrieve from the TOTP-supported application on your phone.
</p>
<div class="relative inline">
<Form v-bind="disable.form()" #default="{ processing }">
<Button variant="destructive" type="submit" :disabled="processing">
Disable 2FA
</Button>
</Form>
</div>
<TwoFactorRecoveryCodes />
</div>
<TwoFactorSetupModal
v-model:isOpen="showSetupModal"
:requiresConfirmation="requiresConfirmation"
:twoFactorEnabled="twoFactorEnabled"
/>
</div>
</template>
+30 -30
View File
@@ -1,44 +1,44 @@
<script setup lang="ts">
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { toUrl } from '@/lib/utils';
import type { NavItem } from '@/types';
type Props = {
items: NavItem[];
class?: string;
items: NavItem[];
class?: string;
};
defineProps<Props>();
</script>
<template>
<SidebarGroup
:class="`group-data-[collapsible=icon]:p-0 ${$props.class || ''}`"
>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton
class="text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-neutral-100"
as-child
>
<a
:href="toUrl(item.href)"
target="_blank"
rel="noopener noreferrer"
>
<component :is="item.icon" />
<span>{{ item.title }}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup
:class="`group-data-[collapsible=icon]:p-0 ${$props.class || ''}`"
>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton
class="text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-neutral-100"
as-child
>
<a
:href="toUrl(item.href)"
target="_blank"
rel="noopener noreferrer"
>
<component :is="item.icon" />
<span>{{ item.title }}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</template>
+23 -23
View File
@@ -1,38 +1,38 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import type { NavItem } from '@/types';
defineProps<{
items: NavItem[];
items: NavItem[];
}>();
const { isCurrentUrl } = useCurrentUrl();
</script>
<template>
<SidebarGroup class="px-2 py-0">
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton
as-child
:is-active="isCurrentUrl(item.href)"
:tooltip="item.title"
>
<Link :href="item.href">
<component :is="item.icon" />
<span>{{ item.title }}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
<SidebarGroup class="px-2 py-0">
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton
as-child
:is-active="isCurrentUrl(item.href)"
:tooltip="item.title"
>
<Link :href="item.href">
<component :is="item.icon" />
<span>{{ item.title }}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</template>
+33 -37
View File
@@ -3,15 +3,15 @@ import { usePage } from '@inertiajs/vue3';
import { ChevronsUpDown } from '@lucide/vue';
import { computed } from 'vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import UserInfo from '@/components/UserInfo.vue';
import UserMenuContent from '@/components/UserMenuContent.vue';
@@ -22,34 +22,30 @@ const { isMobile, state } = useSidebar();
</script>
<template>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
data-test="sidebar-menu-button"
>
<UserInfo :user="user" />
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-(--reka-dropdown-menu-trigger-width) min-w-56 rounded-lg"
:side="
isMobile
? 'bottom'
: state === 'collapsed'
? 'left'
: 'bottom'
"
align="end"
:side-offset="4"
>
<UserMenuContent :user="user" />
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
data-test="sidebar-menu-button"
>
<UserInfo :user="user" />
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-(--reka-dropdown-menu-trigger-width) min-w-56 rounded-lg"
:side="
isMobile ? 'bottom' : state === 'collapsed' ? 'left' : 'bottom'
"
align="end"
:side-offset="4"
>
<UserMenuContent :user="user" />
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</template>
+71 -71
View File
@@ -3,93 +3,93 @@ import { KeyRound, Trash2 } from '@lucide/vue';
import { ref } from 'vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
DialogTrigger,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import type { Passkey } from '@/types/auth';
const props = defineProps<{
passkey: Passkey;
passkey: Passkey;
}>();
const emit = defineEmits<{
remove: [id: number, onError: () => void];
remove: [id: number, onError: () => void];
}>();
const isDeleting = ref(false);
const handleDelete = () => {
isDeleting.value = true;
emit('remove', props.passkey.id, () => {
isDeleting.value = false;
});
isDeleting.value = true;
emit('remove', props.passkey.id, () => {
isDeleting.value = false;
});
};
</script>
<template>
<div class="flex items-center justify-between border-b p-4 last:border-b-0">
<div class="flex items-center gap-4">
<div
class="bg-muted flex h-10 w-10 shrink-0 items-center justify-center rounded-xl"
>
<KeyRound class="text-muted-foreground h-5 w-5" />
</div>
<div class="space-y-1">
<div class="flex items-center gap-2.5">
<p class="font-medium tracking-tight">{{ passkey.name }}</p>
<span
v-if="passkey.authenticator"
class="bg-muted text-muted-foreground ring-border inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] font-medium tracking-wide uppercase ring-1 ring-inset"
>
{{ passkey.authenticator }}
</span>
</div>
<p class="text-muted-foreground text-sm">
Added {{ passkey.created_at_diff }}
<template v-if="passkey.last_used_at_diff">
<span class="text-muted-foreground/50 mx-1">/</span>
Last used {{ passkey.last_used_at_diff }}
</template>
</p>
</div>
<div class="flex items-center justify-between border-b p-4 last:border-b-0">
<div class="flex items-center gap-4">
<div
class="bg-muted flex h-10 w-10 shrink-0 items-center justify-center rounded-xl"
>
<KeyRound class="text-muted-foreground h-5 w-5" />
</div>
<div class="space-y-1">
<div class="flex items-center gap-2.5">
<p class="font-medium tracking-tight">{{ passkey.name }}</p>
<span
v-if="passkey.authenticator"
class="bg-muted text-muted-foreground ring-border inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] font-medium tracking-wide uppercase ring-1 ring-inset"
>
{{ passkey.authenticator }}
</span>
</div>
<Dialog>
<DialogTrigger as-child>
<Button
variant="ghost"
size="sm"
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 class="h-4 w-4" />
<span class="sr-only">Remove</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Remove passkey</DialogTitle>
<DialogDescription>
Are you sure you want to remove the "{{ passkey.name }}"
passkey? You will no longer be able to use it to sign in.
</DialogDescription>
<DialogFooter class="gap-2">
<DialogClose as-child>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button
variant="destructive"
:disabled="isDeleting"
@click="handleDelete"
>
{{ isDeleting ? 'Removing...' : 'Remove passkey' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<p class="text-muted-foreground text-sm">
Added {{ passkey.created_at_diff }}
<template v-if="passkey.last_used_at_diff">
<span class="text-muted-foreground/50 mx-1">/</span>
Last used {{ passkey.last_used_at_diff }}
</template>
</p>
</div>
</div>
<Dialog>
<DialogTrigger as-child>
<Button
variant="ghost"
size="sm"
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 class="h-4 w-4" />
<span class="sr-only">Remove</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Remove passkey</DialogTitle>
<DialogDescription>
Are you sure you want to remove the "{{ passkey.name }}" passkey? You
will no longer be able to use it to sign in.
</DialogDescription>
<DialogFooter class="gap-2">
<DialogClose as-child>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button
variant="destructive"
:disabled="isDeleting"
@click="handleDelete"
>
{{ isDeleting ? 'Removing...' : 'Remove passkey' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
+65 -65
View File
@@ -7,96 +7,96 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const emit = defineEmits<{
success: [];
success: [];
}>();
const getDefaultPasskeyName = () => {
const ua = navigator.userAgent;
const ua = navigator.userAgent;
const browser = [
{ pattern: /Edg|Edge/, name: 'Edge' },
{ pattern: /OPR|Opera|OPiOS/, name: 'Opera' },
{ pattern: /Firefox|FxiOS/, name: 'Firefox' },
{ pattern: /Chrome|CriOS/, name: 'Chrome' },
{ pattern: /Safari/, name: 'Safari' },
].find(({ pattern }) => pattern.test(ua))?.name;
const browser = [
{ pattern: /Edg|Edge/, name: 'Edge' },
{ pattern: /OPR|Opera|OPiOS/, name: 'Opera' },
{ pattern: /Firefox|FxiOS/, name: 'Firefox' },
{ pattern: /Chrome|CriOS/, name: 'Chrome' },
{ pattern: /Safari/, name: 'Safari' },
].find(({ pattern }) => pattern.test(ua))?.name;
const os = [
{ pattern: /iPhone/, name: 'iPhone' },
{ pattern: /iPad|Macintosh(?=.*Mobile)/, name: 'iPad' },
{ pattern: /Android/, name: 'Android' },
{ pattern: /Mac/, name: 'Mac' },
{ pattern: /Windows/, name: 'Windows' },
].find(({ pattern }) => pattern.test(ua))?.name;
const os = [
{ pattern: /iPhone/, name: 'iPhone' },
{ pattern: /iPad|Macintosh(?=.*Mobile)/, name: 'iPad' },
{ pattern: /Android/, name: 'Android' },
{ pattern: /Mac/, name: 'Mac' },
{ pattern: /Windows/, name: 'Windows' },
].find(({ pattern }) => pattern.test(ua))?.name;
return [browser, os].filter(Boolean).join(' on ') || '';
return [browser, os].filter(Boolean).join(' on ') || '';
};
const name = ref(getDefaultPasskeyName());
const showForm = ref(false);
const { register, isLoading, error, isSupported } = usePasskeyRegister({
onSuccess: () => {
name.value = '';
showForm.value = false;
emit('success');
},
onSuccess: () => {
name.value = '';
showForm.value = false;
emit('success');
},
});
const handleSubmit = async (event: Event) => {
event.preventDefault();
event.preventDefault();
if (!name.value.trim()) {
return;
}
if (!name.value.trim()) {
return;
}
await register(name.value);
await register(name.value);
};
const handleCancel = () => {
showForm.value = false;
name.value = '';
showForm.value = false;
name.value = '';
};
</script>
<template>
<div v-if="!isSupported" class="text-muted-foreground text-sm">
Passkeys are not supported in this browser.
<div v-if="!isSupported" class="text-muted-foreground text-sm">
Passkeys are not supported in this browser.
</div>
<Button v-else-if="!showForm" variant="outline" @click="showForm = true">
Add passkey
</Button>
<form
v-else
@submit="handleSubmit"
class="border-border bg-muted/50 space-y-4 rounded-lg border p-4"
>
<div class="grid gap-2">
<Label for="passkey-name">Passkey name</Label>
<Input
id="passkey-name"
type="text"
v-model="name"
placeholder="e.g., MacBook Pro, iPhone"
class="border-foreground/20 mt-1 block w-full"
autofocus
/>
<p class="text-muted-foreground text-xs">
A name helps you identify this passkey later.
</p>
</div>
<Button v-else-if="!showForm" variant="outline" @click="showForm = true">
Add passkey
</Button>
<InputError v-if="error" :message="error" />
<form
v-else
@submit="handleSubmit"
class="border-border bg-muted/50 space-y-4 rounded-lg border p-4"
>
<div class="grid gap-2">
<Label for="passkey-name">Passkey name</Label>
<Input
id="passkey-name"
type="text"
v-model="name"
placeholder="e.g., MacBook Pro, iPhone"
class="border-foreground/20 mt-1 block w-full"
autofocus
/>
<p class="text-muted-foreground text-xs">
A name helps you identify this passkey later.
</p>
</div>
<InputError v-if="error" :message="error" />
<div class="flex gap-2">
<Button type="submit" :disabled="isLoading || !name.trim()">
{{ isLoading ? 'Registering...' : 'Register passkey' }}
</Button>
<Button type="button" variant="ghost" @click="handleCancel">
Cancel
</Button>
</div>
</form>
<div class="flex gap-2">
<Button type="submit" :disabled="isLoading || !name.trim()">
{{ isLoading ? 'Registering...' : 'Register passkey' }}
</Button>
<Button type="button" variant="ghost" @click="handleCancel">
Cancel
</Button>
</div>
</form>
</template>
+50 -50
View File
@@ -9,65 +9,65 @@ import { Separator } from '@/components/ui/separator';
import { Spinner } from '@/components/ui/spinner';
type Props = {
routes?: {
options: UrlMethodPair;
submit: UrlMethodPair;
};
label?: string;
loadingLabel?: string;
separator?: string;
routes?: {
options: UrlMethodPair;
submit: UrlMethodPair;
};
label?: string;
loadingLabel?: string;
separator?: string;
};
const props = defineProps<Props>();
const { verify, isLoading, error, isSupported } = usePasskeyVerify({
...(props.routes
? {
routes: {
options: props.routes.options.url,
submit: props.routes.submit.url,
},
}
: {}),
onSuccess: (response) => {
router.visit(response.redirect ?? '/dashboard');
},
...(props.routes
? {
routes: {
options: props.routes.options.url,
submit: props.routes.submit.url,
},
}
: {}),
onSuccess: (response) => {
router.visit(response.redirect ?? '/dashboard');
},
});
</script>
<template>
<div v-if="isSupported">
<div class="grid gap-2">
<Button
type="button"
variant="outline"
class="w-full"
@click="verify"
:disabled="isLoading"
>
<Spinner v-if="isLoading" />
<KeyRound v-else class="h-4 w-4" />
{{
isLoading
? (props.loadingLabel ?? 'Authenticating...')
: (props.label ?? 'Sign in with a passkey')
}}
</Button>
<div v-if="isSupported">
<div class="grid gap-2">
<Button
type="button"
variant="outline"
class="w-full"
@click="verify"
:disabled="isLoading"
>
<Spinner v-if="isLoading" />
<KeyRound v-else class="h-4 w-4" />
{{
isLoading
? (props.loadingLabel ?? 'Authenticating...')
: (props.label ?? 'Sign in with a passkey')
}}
</Button>
<div v-if="error" class="text-center">
<InputError :message="error" />
</div>
</div>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<Separator class="w-full" />
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-background text-muted-foreground px-2">
{{ props.separator ?? 'Or continue with email' }}
</span>
</div>
</div>
<div v-if="error" class="text-center">
<InputError :message="error" />
</div>
</div>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<Separator class="w-full" />
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-background text-muted-foreground px-2">
{{ props.separator ?? 'Or continue with email' }}
</span>
</div>
</div>
</div>
</template>
+25 -25
View File
@@ -8,39 +8,39 @@ import { cn } from '@/lib/utils';
defineOptions({ inheritAttrs: false });
const props = defineProps<{
class?: HTMLAttributes['class'];
class?: HTMLAttributes['class'];
}>();
const showPassword = ref(false);
const inputRef = useTemplateRef('inputRef');
defineExpose({
$el: inputRef,
focus: () => inputRef.value?.$el?.focus(),
$el: inputRef,
focus: () => inputRef.value?.$el?.focus(),
});
</script>
<template>
<div class="relative">
<Input
ref="inputRef"
:type="showPassword ? 'text' : 'password'"
:class="cn('pr-10', props.class)"
v-bind="$attrs"
/>
<button
type="button"
@click="showPassword = !showPassword"
:class="
cn(
'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:ring-[3px] focus-visible:outline-none',
)
"
:aria-label="showPassword ? 'Hide password' : 'Show password'"
:tabindex="-1"
>
<EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" />
</button>
</div>
<div class="relative">
<Input
ref="inputRef"
:type="showPassword ? 'text' : 'password'"
:class="cn('pr-10', props.class)"
v-bind="$attrs"
/>
<button
type="button"
@click="showPassword = !showPassword"
:class="
cn(
'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:ring-[3px] focus-visible:outline-none',
)
"
:aria-label="showPassword ? 'Hide password' : 'Show password'"
:tabindex="-1"
>
<EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" />
</button>
</div>
</template>
+23 -23
View File
@@ -5,27 +5,27 @@ const patternId = `pattern-${useId()}`;
</script>
<template>
<svg
class="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20"
fill="none"
>
<defs>
<pattern
:id="patternId"
x="0"
y="0"
width="8"
height="8"
patternUnits="userSpaceOnUse"
>
<path d="M-1 5L5 -1M3 9L8.5 3.5" stroke-width="0.5"></path>
</pattern>
</defs>
<rect
stroke="none"
:fill="`url(#${patternId})`"
width="100%"
height="100%"
></rect>
</svg>
<svg
class="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20"
fill="none"
>
<defs>
<pattern
:id="patternId"
x="0"
y="0"
width="8"
height="8"
patternUnits="userSpaceOnUse"
>
<path d="M-1 5L5 -1M3 9L8.5 3.5" stroke-width="0.5"></path>
</pattern>
</defs>
<rect
stroke="none"
:fill="`url(#${patternId})`"
width="100%"
height="100%"
></rect>
</svg>
</template>
+13 -13
View File
@@ -3,23 +3,23 @@ import type { LinkComponentBaseProps, Method } from '@inertiajs/core';
import { Link } from '@inertiajs/vue3';
type Props = {
href: LinkComponentBaseProps['href'];
tabindex?: number;
method?: Method;
as?: string;
href: LinkComponentBaseProps['href'];
tabindex?: number;
method?: Method;
as?: string;
};
defineProps<Props>();
</script>
<template>
<Link
:href="href"
:tabindex="tabindex"
:method="method"
:as="as"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
<slot />
</Link>
<Link
:href="href"
:tabindex="tabindex"
:method="method"
:as="as"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
<slot />
</Link>
</template>
@@ -5,11 +5,11 @@ import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
import AlertError from '@/components/AlertError.vue';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { regenerateRecoveryCodes } from '@/routes/two-factor';
@@ -19,105 +19,93 @@ const isRecoveryCodesVisible = ref<boolean>(false);
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef');
const toggleRecoveryCodesVisibility = async () => {
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) {
await fetchRecoveryCodes();
}
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) {
await fetchRecoveryCodes();
}
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
if (isRecoveryCodesVisible.value) {
await nextTick();
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
}
if (isRecoveryCodesVisible.value) {
await nextTick();
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
}
};
onMounted(async () => {
if (!recoveryCodesList.value.length) {
await fetchRecoveryCodes();
}
if (!recoveryCodesList.value.length) {
await fetchRecoveryCodes();
}
});
</script>
<template>
<Card class="w-full">
<CardHeader>
<CardTitle class="flex gap-3">
<LockKeyhole class="size-4" />2FA recovery codes
</CardTitle>
<CardDescription>
Recovery codes let you regain access if you lose your 2FA
device. Store them in a secure password manager.
</CardDescription>
</CardHeader>
<CardContent>
<div
class="flex flex-col gap-3 select-none sm:flex-row sm:items-center sm:justify-between"
>
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
<component
:is="isRecoveryCodesVisible ? EyeOff : Eye"
class="size-4"
/>
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} recovery
codes
</Button>
<Card class="w-full">
<CardHeader>
<CardTitle class="flex gap-3">
<LockKeyhole class="size-4" />2FA recovery codes
</CardTitle>
<CardDescription>
Recovery codes let you regain access if you lose your 2FA device. Store
them in a secure password manager.
</CardDescription>
</CardHeader>
<CardContent>
<div
class="flex flex-col gap-3 select-none sm:flex-row sm:items-center sm:justify-between"
>
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
<component
:is="isRecoveryCodesVisible ? EyeOff : Eye"
class="size-4"
/>
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} recovery codes
</Button>
<Form
v-if="isRecoveryCodesVisible && recoveryCodesList.length"
v-bind="regenerateRecoveryCodes.form()"
method="post"
:options="{ preserveScroll: true }"
@success="fetchRecoveryCodes"
#default="{ processing }"
>
<Button
variant="secondary"
type="submit"
:disabled="processing"
>
<RefreshCw /> Regenerate codes
</Button>
</Form>
<Form
v-if="isRecoveryCodesVisible && recoveryCodesList.length"
v-bind="regenerateRecoveryCodes.form()"
method="post"
:options="{ preserveScroll: true }"
@success="fetchRecoveryCodes"
#default="{ processing }"
>
<Button variant="secondary" type="submit" :disabled="processing">
<RefreshCw /> Regenerate codes
</Button>
</Form>
</div>
<div
:class="[
'relative overflow-hidden transition-all duration-300',
isRecoveryCodesVisible ? 'h-auto opacity-100' : 'h-0 opacity-0',
]"
>
<div v-if="errors?.length" class="mt-6">
<AlertError :errors="errors" />
</div>
<div v-else class="mt-3 space-y-3">
<div
ref="recoveryCodeSectionRef"
class="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm"
>
<div v-if="!recoveryCodesList.length" class="space-y-2">
<div
v-for="n in 8"
:key="n"
class="bg-muted-foreground/20 h-4 animate-pulse rounded"
></div>
</div>
<div
:class="[
'relative overflow-hidden transition-all duration-300',
isRecoveryCodesVisible
? 'h-auto opacity-100'
: 'h-0 opacity-0',
]"
>
<div v-if="errors?.length" class="mt-6">
<AlertError :errors="errors" />
</div>
<div v-else class="mt-3 space-y-3">
<div
ref="recoveryCodeSectionRef"
class="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm"
>
<div v-if="!recoveryCodesList.length" class="space-y-2">
<div
v-for="n in 8"
:key="n"
class="bg-muted-foreground/20 h-4 animate-pulse rounded"
></div>
</div>
<div
v-else
v-for="(code, index) in recoveryCodesList"
:key="index"
>
{{ code }}
</div>
</div>
<p class="text-muted-foreground text-xs select-none">
Each recovery code can be used once to access your
account and will be removed after use. If you need more,
click
<span class="font-bold">Regenerate codes</span> above.
</p>
</div>
<div v-else v-for="(code, index) in recoveryCodesList" :key="index">
{{ code }}
</div>
</CardContent>
</Card>
</div>
<p class="text-muted-foreground text-xs select-none">
Each recovery code can be used once to access your account and will
be removed after use. If you need more, click
<span class="font-bold">Regenerate codes</span> above.
</p>
</div>
</div>
</CardContent>
</Card>
</template>
+219 -240
View File
@@ -7,16 +7,16 @@ import AlertError from '@/components/AlertError.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/input-otp';
import { Spinner } from '@/components/ui/spinner';
import { useAppearance } from '@/composables/useAppearance';
@@ -25,8 +25,8 @@ import { confirm } from '@/routes/two-factor';
import type { TwoFactorConfigContent } from '@/types';
type Props = {
requiresConfirmation: boolean;
twoFactorEnabled: boolean;
requiresConfirmation: boolean;
twoFactorEnabled: boolean;
};
const { resolvedAppearance } = useAppearance();
@@ -36,7 +36,7 @@ const isOpen = defineModel<boolean>('isOpen');
const { copy, copied } = useClipboard();
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } =
useTwoFactorAuth();
useTwoFactorAuth();
const showVerificationStep = ref(false);
const code = ref<string>('');
@@ -44,255 +44,234 @@ const code = ref<string>('');
const pinInputContainerRef = useTemplateRef('pinInputContainerRef');
const modalConfig = computed<TwoFactorConfigContent>(() => {
if (props.twoFactorEnabled) {
return {
title: 'Two-factor authentication enabled',
description:
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
buttonText: 'Close',
};
}
if (showVerificationStep.value) {
return {
title: 'Verify authentication code',
description: 'Enter the 6-digit code from your authenticator app',
buttonText: 'Continue',
};
}
if (props.twoFactorEnabled) {
return {
title: 'Enable two-factor authentication',
description:
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
buttonText: 'Continue',
title: 'Two-factor authentication enabled',
description:
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
buttonText: 'Close',
};
}
if (showVerificationStep.value) {
return {
title: 'Verify authentication code',
description: 'Enter the 6-digit code from your authenticator app',
buttonText: 'Continue',
};
}
return {
title: 'Enable two-factor authentication',
description:
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
buttonText: 'Continue',
};
});
const handleModalNextStep = () => {
if (props.requiresConfirmation) {
showVerificationStep.value = true;
if (props.requiresConfirmation) {
showVerificationStep.value = true;
nextTick(() => {
pinInputContainerRef.value?.querySelector('input')?.focus();
});
nextTick(() => {
pinInputContainerRef.value?.querySelector('input')?.focus();
});
return;
}
return;
}
clearSetupData();
isOpen.value = false;
clearSetupData();
isOpen.value = false;
};
const resetModalState = () => {
if (props.twoFactorEnabled) {
clearSetupData();
}
if (props.twoFactorEnabled) {
clearSetupData();
}
showVerificationStep.value = false;
code.value = '';
showVerificationStep.value = false;
code.value = '';
};
watch(
() => isOpen.value,
async (isOpen) => {
if (!isOpen) {
resetModalState();
() => isOpen.value,
async (isOpen) => {
if (!isOpen) {
resetModalState();
return;
}
return;
}
if (!qrCodeSvg.value) {
await fetchSetupData();
}
},
if (!qrCodeSvg.value) {
await fetchSetupData();
}
},
);
</script>
<template>
<Dialog :open="isOpen" @update:open="isOpen = $event">
<DialogContent class="sm:max-w-md">
<DialogHeader class="flex items-center justify-center">
<div
class="border-border bg-card mb-3 w-auto rounded-full border p-0.5 shadow-sm"
>
<div
class="border-border bg-muted relative overflow-hidden rounded-full border p-2.5"
>
<div
class="absolute inset-0 grid grid-cols-5 opacity-50"
>
<div
v-for="i in 5"
:key="`col-${i}`"
class="border-border border-r last:border-r-0"
/>
</div>
<div
class="absolute inset-0 grid grid-rows-5 opacity-50"
>
<div
v-for="i in 5"
:key="`row-${i}`"
class="border-border border-b last:border-b-0"
/>
</div>
<ScanLine
class="text-foreground relative z-20 size-6"
/>
</div>
</div>
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
<DialogDescription class="text-center">
{{ modalConfig.description }}
</DialogDescription>
</DialogHeader>
<div
class="relative flex w-auto flex-col items-center justify-center space-y-5"
>
<template v-if="!showVerificationStep">
<AlertError v-if="errors?.length" :errors="errors" />
<template v-else>
<div
class="relative mx-auto flex max-w-md items-center overflow-hidden"
>
<div
class="border-border relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border"
>
<div
v-if="!qrCodeSvg"
class="bg-background absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center"
>
<Spinner class="size-6" />
</div>
<div
v-else
class="relative z-10 overflow-hidden border p-5"
>
<div
v-html="qrCodeSvg"
class="flex aspect-square size-full items-center justify-center"
:style="{
filter:
resolvedAppearance === 'dark'
? 'invert(1) brightness(1.5)'
: undefined,
}"
/>
</div>
</div>
</div>
<div class="flex w-full items-center space-x-5">
<Button class="w-full" @click="handleModalNextStep">
{{ modalConfig.buttonText }}
</Button>
</div>
<div
class="relative flex w-full items-center justify-center"
>
<div
class="bg-border absolute inset-0 top-1/2 h-px w-full"
/>
<span class="bg-card relative px-2 py-1"
>or, enter the code manually</span
>
</div>
<div
class="flex w-full items-center justify-center space-x-2"
>
<div
class="border-border flex w-full items-stretch overflow-hidden rounded-xl border"
>
<div
v-if="!manualSetupKey"
class="bg-muted flex h-full w-full items-center justify-center p-3"
>
<Spinner />
</div>
<template v-else>
<input
type="text"
readonly
:value="manualSetupKey"
class="bg-background text-foreground h-full w-full p-3"
/>
<button
@click="copy(manualSetupKey || '')"
class="border-border hover:bg-muted relative block h-auto border-l px-3"
>
<Check
v-if="copied"
class="w-4 text-green-500"
/>
<Copy v-else class="w-4" />
</button>
</template>
</div>
</div>
</template>
</template>
<template v-else>
<Form
v-bind="confirm.form()"
error-bag="confirmTwoFactorAuthentication"
reset-on-error
@finish="code = ''"
@success="isOpen = false"
v-slot="{ errors, processing }"
>
<input type="hidden" name="code" :value="code" />
<div
ref="pinInputContainerRef"
class="relative w-full space-y-3"
>
<div
class="flex w-full flex-col items-center justify-center space-y-3 py-2"
>
<InputOTP
id="otp"
v-model="code"
:maxlength="6"
:disabled="processing"
autofocus
>
<InputOTPGroup>
<InputOTPSlot
v-for="index in 6"
:key="index"
:index="index - 1"
/>
</InputOTPGroup>
</InputOTP>
<InputError :message="errors?.code" />
</div>
<div class="flex w-full items-center space-x-5">
<Button
type="button"
variant="outline"
class="w-auto flex-1"
@click="showVerificationStep = false"
:disabled="processing"
>
Back
</Button>
<Button
type="submit"
class="w-auto flex-1"
:disabled="processing || code.length < 6"
>
Confirm
</Button>
</div>
</div>
</Form>
</template>
<Dialog :open="isOpen" @update:open="isOpen = $event">
<DialogContent class="sm:max-w-md">
<DialogHeader class="flex items-center justify-center">
<div
class="border-border bg-card mb-3 w-auto rounded-full border p-0.5 shadow-sm"
>
<div
class="border-border bg-muted relative overflow-hidden rounded-full border p-2.5"
>
<div class="absolute inset-0 grid grid-cols-5 opacity-50">
<div
v-for="i in 5"
:key="`col-${i}`"
class="border-border border-r last:border-r-0"
/>
</div>
</DialogContent>
</Dialog>
<div class="absolute inset-0 grid grid-rows-5 opacity-50">
<div
v-for="i in 5"
:key="`row-${i}`"
class="border-border border-b last:border-b-0"
/>
</div>
<ScanLine class="text-foreground relative z-20 size-6" />
</div>
</div>
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
<DialogDescription class="text-center">
{{ modalConfig.description }}
</DialogDescription>
</DialogHeader>
<div
class="relative flex w-auto flex-col items-center justify-center space-y-5"
>
<template v-if="!showVerificationStep">
<AlertError v-if="errors?.length" :errors="errors" />
<template v-else>
<div
class="relative mx-auto flex max-w-md items-center overflow-hidden"
>
<div
class="border-border relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border"
>
<div
v-if="!qrCodeSvg"
class="bg-background absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center"
>
<Spinner class="size-6" />
</div>
<div v-else class="relative z-10 overflow-hidden border p-5">
<div
v-html="qrCodeSvg"
class="flex aspect-square size-full items-center justify-center"
:style="{
filter:
resolvedAppearance === 'dark'
? 'invert(1) brightness(1.5)'
: undefined,
}"
/>
</div>
</div>
</div>
<div class="flex w-full items-center space-x-5">
<Button class="w-full" @click="handleModalNextStep">
{{ modalConfig.buttonText }}
</Button>
</div>
<div class="relative flex w-full items-center justify-center">
<div class="bg-border absolute inset-0 top-1/2 h-px w-full" />
<span class="bg-card relative px-2 py-1"
>or, enter the code manually</span
>
</div>
<div class="flex w-full items-center justify-center space-x-2">
<div
class="border-border flex w-full items-stretch overflow-hidden rounded-xl border"
>
<div
v-if="!manualSetupKey"
class="bg-muted flex h-full w-full items-center justify-center p-3"
>
<Spinner />
</div>
<template v-else>
<input
type="text"
readonly
:value="manualSetupKey"
class="bg-background text-foreground h-full w-full p-3"
/>
<button
@click="copy(manualSetupKey || '')"
class="border-border hover:bg-muted relative block h-auto border-l px-3"
>
<Check v-if="copied" class="w-4 text-green-500" />
<Copy v-else class="w-4" />
</button>
</template>
</div>
</div>
</template>
</template>
<template v-else>
<Form
v-bind="confirm.form()"
error-bag="confirmTwoFactorAuthentication"
reset-on-error
@finish="code = ''"
@success="isOpen = false"
v-slot="{ errors, processing }"
>
<input type="hidden" name="code" :value="code" />
<div ref="pinInputContainerRef" class="relative w-full space-y-3">
<div
class="flex w-full flex-col items-center justify-center space-y-3 py-2"
>
<InputOTP
id="otp"
v-model="code"
:maxlength="6"
:disabled="processing"
autofocus
>
<InputOTPGroup>
<InputOTPSlot
v-for="index in 6"
:key="index"
:index="index - 1"
/>
</InputOTPGroup>
</InputOTP>
<InputError :message="errors?.code" />
</div>
<div class="flex w-full items-center space-x-5">
<Button
type="button"
variant="outline"
class="w-auto flex-1"
@click="showVerificationStep = false"
:disabled="processing"
>
Back
</Button>
<Button
type="submit"
class="w-auto flex-1"
:disabled="processing || code.length < 6"
>
Confirm
</Button>
</div>
</div>
</Form>
</template>
</div>
</DialogContent>
</Dialog>
</template>
+16 -16
View File
@@ -5,34 +5,34 @@ import { useInitials } from '@/composables/useInitials';
import type { User } from '@/types';
type Props = {
user: User;
showEmail?: boolean;
user: User;
showEmail?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
showEmail: false,
showEmail: false,
});
const { getInitials } = useInitials();
// Compute whether we should show the avatar image
const showAvatar = computed(
() => props.user.avatar && props.user.avatar !== '',
() => props.user.avatar && props.user.avatar !== '',
);
</script>
<template>
<Avatar class="h-8 w-8 overflow-hidden rounded-lg">
<AvatarImage v-if="showAvatar" :src="user.avatar!" :alt="user.name" />
<AvatarFallback class="rounded-lg text-black dark:text-white">
{{ getInitials(user.name) }}
</AvatarFallback>
</Avatar>
<Avatar class="h-8 w-8 overflow-hidden rounded-lg">
<AvatarImage v-if="showAvatar" :src="user.avatar!" :alt="user.name" />
<AvatarFallback class="rounded-lg text-black dark:text-white">
{{ getInitials(user.name) }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span>
<span v-if="showEmail" class="text-muted-foreground truncate text-xs">{{
user.email
}}</span>
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span>
<span v-if="showEmail" class="text-muted-foreground truncate text-xs">{{
user.email
}}</span>
</div>
</template>
+31 -31
View File
@@ -2,10 +2,10 @@
import { Link, router } from '@inertiajs/vue3';
import { LogOut, Settings } from '@lucide/vue';
import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import UserInfo from '@/components/UserInfo.vue';
import { logout } from '@/routes';
@@ -13,42 +13,42 @@ import { edit } from '@/routes/profile';
import type { User } from '@/types';
type Props = {
user: User;
user: User;
};
const handleLogout = () => {
router.flushAll();
router.flushAll();
};
defineProps<Props>();
</script>
<template>
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<UserInfo :user="user" :show-email="true" />
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem :as-child="true">
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
<Settings class="mr-2 h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<UserInfo :user="user" :show-email="true" />
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem :as-child="true">
<Link
class="block w-full cursor-pointer"
:href="logout()"
@click="handleLogout"
as="button"
data-test="logout-button"
>
<LogOut class="mr-2 h-4 w-4" />
Log out
</Link>
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
<Settings class="mr-2 h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem :as-child="true">
<Link
class="block w-full cursor-pointer"
:href="logout()"
@click="handleLogout"
as="button"
data-test="logout-button"
>
<LogOut class="mr-2 h-4 w-4" />
Log out
</Link>
</DropdownMenuItem>
</template>