Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions resources/js/app.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useDialog } from '@/composables/useDialog';
import { registerGlobalComponent } from '@/lib/globalComponents';
import { registerAction, registerIcon } from '@/lib/navigation';
import { router } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { LogOut } from 'lucide-vue-next';
import IconLogOut from '~icons/lucide/log-out';
import ImpersonationAlert from './components/ImpersonationAlert.vue';

import '../css/style.css';

Expand All @@ -12,10 +14,9 @@ import '../css/style.css';
* Called during app initialization before mounting
*/
export function setup() {
console.debug('Auth module loaded');

registerIcon('logout', IconLogOut);
registerAuthActions();
registerGlobalComponent('top', ImpersonationAlert);
}

/**
Expand Down
216 changes: 216 additions & 0 deletions resources/js/components/ImpersonationAlert.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import type { User } from '@/types';
import { router, usePage } from '@inertiajs/vue3';
import { onClickOutside, onKeyStroke } from '@vueuse/core';
import { Drama, HistoryIcon, X } from 'lucide-vue-next';
import { computed, ref } from 'vue';

interface Impersonation {
user: User;
route: string;
label: string;
recent: User[];
}

const page = usePage();

const impersonation = computed<Impersonation | null>(
() => (page.props?.impersonation as Impersonation) || null,
Comment thread
roble marked this conversation as resolved.
);

// User initials helper
const getUserInitials = (name: string) => {
return name
.split(' ')
.map((word) => word.charAt(0).toUpperCase())
.slice(0, 2)
.join('');
};

// Role badge helper
const getRoleBadgeClasses = (role: string) => {
const baseClasses =
'shrink-0 rounded-xl px-1 py-0.5 text-[9px] text-white uppercase';
const colorClasses = role === 'admin' ? 'bg-red-800' : 'bg-cyan-700';

return cn(baseClasses, colorClasses);
};

// State management
const isExpanded = ref(false);
const alertRef = ref<HTMLElement | null>(null);

// Actions
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
const collapse = () => {
isExpanded.value = false;
};
const reimpersonate = (userId: number) => {
router.post(
route('auth.impersonate.reimpersonate', { userId }),
{},
{
preserveScroll: true,
onSuccess: () => {
collapse();
},
},
);
};

// Interaction handlers
onClickOutside(alertRef, () => {
if (isExpanded.value) collapse();
});

onKeyStroke('Escape', () => {
if (isExpanded.value) collapse();
});

// Positioning classes
const containerClasses = computed(() => cn('fixed bottom-3 right-3 z-50'));
</script>

<template>
<div
v-if="impersonation"
ref="alertRef"
:class="containerClasses"
data-testid="impersonation-alert"
>
<!-- Collapsed state: Avatar only with orange border -->
<button
v-if="!isExpanded"
@click="toggleExpanded"
:title="`Impersonating ${impersonation.user.name}`"
class="animate-in fade-in zoom-in-95 relative cursor-pointer rounded-xl shadow-lg ring-2 ring-orange-500 transition-all duration-300 hover:shadow-xl hover:ring-orange-600"
:aria-label="$t('Show impersonation details')"
:aria-expanded="false"
>
<Avatar class="size-10 bg-gray-100">
<AvatarImage
:src="impersonation.user.avatar"
:alt="impersonation.user.name"
/>
<AvatarFallback class="bg-yellow-600 text-sm text-white">
{{ getUserInitials(impersonation.user.name) }}
</AvatarFallback>
</Avatar>
<!-- Impersonation icon badge -->
<div
class="absolute -top-3 -left-3 flex size-7 items-center justify-center rounded-xl bg-orange-500 shadow-lg"
>
<Drama class="size-5 text-white" />
</div>
</button>

<!-- Expanded state: Full card with user info and action button -->
<div
v-else
class="bg-foreground animate-in fade-in slide-in-from-right-5 relative flex w-80 flex-col gap-3 rounded-xl p-3 shadow-2xl duration-300"
role="region"
:aria-label="$t('Impersonation alert')"
>
<!-- Close button -->
<button
@click="collapse"
class="text-background/60 hover:bg-background/10 hover:text-background absolute top-3 right-3 rounded-xl p-1 transition-colors"
:aria-label="$t('Close')"
>
<X class="size-5" />
</button>
<div class="flex items-center gap-3">
<Avatar class="size-11 border-2 border-amber-500">
<AvatarImage
:src="impersonation.user.avatar"
:alt="impersonation.user.name"
/>
<AvatarFallback class="bg-yellow-600 text-sm text-white">
{{ getUserInitials(impersonation.user.name) }}
</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p
class="text-background truncate text-sm font-semibold"
>
{{ impersonation.user.name }}
</p>
<span
v-if="impersonation.user.role"
:class="
getRoleBadgeClasses(impersonation.user.role)
"
>
{{ impersonation.user.role }}
</span>
</div>
<p class="text-background/80 truncate text-xs">
{{ impersonation.user.email }}
</p>
</div>
</div>
<a
:href="impersonation.route"
class="bg-background text-foreground hover:bg-background/90 w-full rounded-xl px-3 py-2 text-center text-sm font-medium transition-colors"
>
{{ impersonation.label }}
</a>

<!-- Recent History Section -->
<div
v-if="impersonation.recent && impersonation.recent.length > 0"
class="border-background/10 mt-1 border-t pt-2"
>
<p
class="text-background/50 mb-3 text-center text-sm font-medium tracking-wide"
>
<HistoryIcon class="inline-block size-4" />
{{ $t('Recent impersonated users') }}
</p>

<div class="space-y-0">
<button
v-for="user in impersonation.recent"
:key="user.id"
@click="reimpersonate(user.id)"
class="hover:bg-background/20 flex w-full items-center rounded-xl text-left transition-colors"
>
<Avatar
class="border-background/20 m-1 size-10 border-2"
>
<AvatarImage :src="user.avatar" :alt="user.name" />
<AvatarFallback
class="bg-yellow-600/80 text-xs text-white"
>
{{ getUserInitials(user.name) }}
</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1 p-2 pl-1">
<div class="flex items-center gap-1.5">
<p
class="text-background truncate text-xs font-medium"
>
{{ user.name }}
</p>
<span
v-if="user.role"
:class="getRoleBadgeClasses(user.role)"
>
{{ user.role }}
</span>
</div>
<p class="text-background/70 truncate text-xs">
{{ user.email }}
</p>
</div>
</button>
</div>
</div>
</div>
</div>
</template>
4 changes: 2 additions & 2 deletions resources/js/components/SocialiteProviders.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button';
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import IconGithub from '~icons/simple-icons/github';
import IconGoogle from '~icons/simple-icons/google';

Expand Down Expand Up @@ -32,7 +32,7 @@ const lastUsed = computed(() => usePage().props.auth.last_social_provider);
<span
v-if="lastUsed === name"
:data-testid="`last-used-badge-${name}`"
class="absolute -top-2 -right-2 rounded-xl bg-muted/80 border px-2 py-0.5 text-xs text-muted-foreground drop-shadow-lg"
class="bg-muted/80 text-muted-foreground absolute -top-2 -right-2 rounded-xl border px-2 py-0.5 text-xs drop-shadow-lg"
>
{{ $t('Last used') }}
</span>
Expand Down
52 changes: 29 additions & 23 deletions resources/js/layouts/AuthCardLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,29 +29,35 @@ defineProps<{
</Link>
</div>

<div class="grow flex w-full flex-col items-center">
<div class="w-full px-4 min-[450px]:w-auto min-[450px]:px-0 min-[450px]:min-w-md">
<Card :class="cardClass">
<CardHeader class="px-8 text-center">
<CardTitle class="text-2xl">
{{ title }}
</CardTitle>
<CardDescription>
{{ description }}
</CardDescription>
</CardHeader>
<CardContent class="px-8">
<PageTransition>
<AlertMessage
:message="$page.props.status || $page.props.error"
:variant="$page.props.status ? 'success' : 'error'"
class="mt-4"
data-testid="alert"
/>
<slot />
</PageTransition>
</CardContent>
</Card>
<div class="flex w-full grow flex-col items-center">
<div
class="w-full px-4 min-[450px]:w-auto min-[450px]:min-w-md min-[450px]:px-0"
>
<Card :class="cardClass">
<CardHeader class="px-8 text-center">
<CardTitle class="text-2xl">
{{ title }}
</CardTitle>
<CardDescription>
{{ description }}
</CardDescription>
</CardHeader>
<CardContent class="px-8">
<PageTransition>
<AlertMessage
:message="
$page.props.status || $page.props.error
"
:variant="
$page.props.status ? 'success' : 'error'
"
class="mt-4"
data-testid="alert"
/>
<slot />
</PageTransition>
</CardContent>
</Card>
</div>
<slot name="outside" />
</div>
Expand Down
4 changes: 3 additions & 1 deletion resources/js/pages/ForgotPassword.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ const email = computed(() =>
autocomplete="email"
/>

<div class="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:items-center sm:justify-between">
<div
class="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:items-center sm:justify-between"
>
<Link
:href="route('login')"
class="mt-4 text-center text-sm text-gray-600 hover:text-gray-900 sm:mt-0 sm:text-left dark:text-gray-400 dark:hover:text-gray-100"
Expand Down
7 changes: 3 additions & 4 deletions resources/js/pages/Login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ const emailRef = ref('');
const forgotUrl = computed(() =>
route('password.request', { email: emailRef.value }),
);

</script>

<template>
Expand Down Expand Up @@ -74,7 +73,7 @@ const forgotUrl = computed(() =>
<Link
v-if="route().has('password.request')"
:href="forgotUrl"
class="ml-auto inline-block text-sm font-medium text-primary hover:underline underline-offset-4"
class="text-primary ml-auto inline-block text-sm font-medium underline-offset-4 hover:underline"
data-testid="forgot-password-link"
:data-invalid="false"
>
Expand All @@ -94,7 +93,7 @@ const forgotUrl = computed(() =>
<Link
v-if="$page.props.auth.magic_link_enabled"
:href="route('magic-link.create')"
class="font-medium text-primary/70 hover:underline underline-offset-4"
class="text-primary/70 font-medium underline-offset-4 hover:underline"
data-testid="magic-link-login-link"
>
{{ $t('Login with magic link') }}
Expand All @@ -107,7 +106,7 @@ const forgotUrl = computed(() =>
{{ $t("Don't have an account?") }}
<Link
:href="route('register')"
class="font-medium text-primary hover:underline underline-offset-4"
class="text-primary font-medium underline-offset-4 hover:underline"
data-testid="sign-up-link"
>
{{ $t('Sign up') }}
Expand Down
8 changes: 6 additions & 2 deletions resources/js/pages/MagicLink.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ defineProps<{
<template>
<AuthCardLayout
:title="$t('Magic Link Login')"
:description="$t('Enter your email to receive a secure, one-time login link.')"
:description="
$t('Enter your email to receive a secure, one-time login link.')
"
:status="status"
>
<Form
Expand All @@ -33,7 +35,9 @@ defineProps<{
data-testid="magic-link-email"
/>

<div class="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:items-center sm:justify-between">
<div
class="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:items-center sm:justify-between"
>
<Link
:href="route('login')"
class="mt-4 text-center text-sm text-gray-600 hover:text-gray-900 sm:mt-0 sm:text-left dark:text-gray-400 dark:hover:text-gray-100"
Expand Down
2 changes: 1 addition & 1 deletion resources/js/pages/Register.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import AuthCardLayout from '../layouts/AuthCardLayout.vue';
{{ $t('Already registered?') }}
<Link
:href="route('login')"
class="font-medium text-primary/70 hover:underline underline-offset-4"
class="text-primary/70 font-medium underline-offset-4 hover:underline"
data-testid="login-link"
>
{{ $t('Log in') }}
Expand Down
Loading
Loading