Compare commits

...

5 Commits

Author SHA1 Message Date
Roman Krček
ffbd3c7cda Home styling 2025-07-14 21:39:49 +02:00
Roman Krček
5d957b18ee More notifications in participants table 2025-07-14 21:37:05 +02:00
Roman Krček
396d29c76b Make emails editable 2025-07-14 21:25:57 +02:00
Roman Krček
d0f555a7c5 Minor styling changes 2025-07-14 16:05:29 +02:00
Roman Krček
f14213a5d4 Add role base access control for events module 2025-07-14 15:50:07 +02:00
13 changed files with 376 additions and 105 deletions

View File

@@ -11,6 +11,10 @@ Basics: These you need to really follow!
- server: $locals.supabase - server: $locals.supabase
- Avoid unnceessary iterations. Once the problem is solved, ask me if i want to to continue and only then continue iterating. - Avoid unnceessary iterations. Once the problem is solved, ask me if i want to to continue and only then continue iterating.
- Avoid sweeping changes throught the project. If you want to change something globally, ask me first. - Avoid sweeping changes throught the project. If you want to change something globally, ask me first.
- to add a notification, use the toast component
- example: toast.success, toast.info, toast.warning, toast.error
Do not fall back to the legacy $: label syntax or Svelte 3/4 stores! This is important! Do not fall back to the legacy $: label syntax or Svelte 3/4 stores! This is important!

13
src/app.d.ts vendored
View File

@@ -1,17 +1,30 @@
import type { Session, SupabaseClient, User } from '@supabase/supabase-js' import type { Session, SupabaseClient, User } from '@supabase/supabase-js'
import type { Database } from './database.types.ts' // import generated types import type { Database } from './database.types.ts' // import generated types
// Define the profile type based on the database schema
type Profile = {
display_name: string | null
section_position: string | null
section: {
name: string | null
} | null
}
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
interface Locals { interface Locals {
supabase: SupabaseClient<Database> supabase: SupabaseClient<Database>
safeGetSession: () => Promise<{ session: Session | null; user: User | null }> safeGetSession: () => Promise<{ session: Session | null; user: User | null }>
getUserProfile: (userId: string) => Promise<Profile | null>
session: Session | null session: Session | null
user: User | null user: User | null
profile: Profile | null
} }
interface PageData { interface PageData {
session: Session | null session: Session | null
user: User | null
profile: Profile | null
} }
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}

View File

@@ -51,6 +51,22 @@ const supabase: Handle = async ({ event, resolve }) => {
return { session, user } return { session, user }
} }
/**
* Fetch user profile data including display name, section position, and section name
*/
event.locals.getUserProfile = async (userId) => {
if (!userId) return null
const { data: profile, error } = await event.locals.supabase
.from('profiles')
.select('display_name, section_position, section:sections (name)')
.eq('id', userId)
.single()
if (error) return null
return profile
}
return resolve(event, { return resolve(event, {
filterSerializedResponseHeaders(name) { filterSerializedResponseHeaders(name) {
/** /**
@@ -67,6 +83,11 @@ const authGuard: Handle = async ({ event, resolve }) => {
event.locals.session = session event.locals.session = session
event.locals.user = user event.locals.user = user
// Fetch the user's profile if they're authenticated
if (user) {
event.locals.profile = await event.locals.getUserProfile(user.id)
}
if (!event.locals.session && event.url.pathname.startsWith('/private')) { if (!event.locals.session && event.url.pathname.startsWith('/private')) {
redirect(303, '/auth') redirect(303, '/auth')
} }
@@ -75,6 +96,13 @@ const authGuard: Handle = async ({ event, resolve }) => {
redirect(303, '/private/home') redirect(303, '/private/home')
} }
// Role-based access control for events routes
if (event.url.pathname.startsWith('/private/events')) {
if (!event.locals.profile || event.locals.profile.section_position !== 'events_manager') {
redirect(303, '/private/errors/events/denied')
}
}
return resolve(event) return resolve(event)
} }

View File

@@ -1,9 +1,18 @@
import type { LayoutServerLoad } from './$types' import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => { export const load: LayoutServerLoad = async ({ locals: { safeGetSession, getUserProfile }, cookies }) => {
const { session } = await safeGetSession() const { session, user } = await safeGetSession()
// Get the user profile if the user is authenticated
let profile = null
if (user) {
profile = await getUserProfile(user.id)
}
return { return {
session, session,
user,
profile,
cookies: cookies.getAll(), cookies: cookies.getAll(),
} }
} }

View File

@@ -39,5 +39,10 @@ export const load: LayoutLoad = async ({ data, depends, fetch }) => {
data: { user }, data: { user },
} = await supabase.auth.getUser() } = await supabase.auth.getUser()
return { session, supabase, user } return {
session,
supabase,
user,
profile: data.profile
}
} }

View File

@@ -1,5 +1,4 @@
<div class="min-h-screen flex flex-col justify-center items-center"> <div class="min-h-screen flex flex-col justify-center items-center">
<!-- SVG QR Code Art on Top -->
<div class="mb-8"> <div class="mb-8">
<img class="w-32 h-auto" src="/qr-code.png" alt=""> <img class="w-32 h-auto" src="/qr-code.png" alt="">
</div> </div>

View File

@@ -3,6 +3,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import ToastContainer from '$lib/components/ToastContainer.svelte'; import ToastContainer from '$lib/components/ToastContainer.svelte';
let { data, children } = $props();
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
@@ -17,12 +19,13 @@
<nav class="border-b border-gray-300 bg-gray-50 p-2 text-gray-900"> <nav class="border-b border-gray-300 bg-gray-50 p-2 text-gray-900">
<div class="container mx-auto max-w-2xl p-2"> <div class="container mx-auto max-w-2xl p-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-lg font-bold">ScanWave</div> <a href="/private/home" class="text-lg font-bold" aria-label="ScanWave Home">ScanWave</a>
<ul class="flex space-x-4"> <ul class="flex space-x-4">
<li><a href="/private/home">Home</a></li>
<li><a href="/private/scanner">Scanner</a></li> <li><a href="/private/scanner">Scanner</a></li>
<li><a href="/private/events">Events</a></li> {#if data.profile?.section_position === 'events_manager'}
<li><a href="/private/events">Events</a></li>
{/if}
</ul> </ul>
</div> </div>
</div> </div>
@@ -31,7 +34,7 @@
<div class="container mx-auto max-w-2xl bg-white p-2"> <div class="container mx-auto max-w-2xl bg-white p-2">
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<slot /> {@render children()}
</QueryClientProvider> </QueryClientProvider>
</div> </div>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
// Get the profile from the page data if available
let { data } = $props();
let profile = $derived(data.profile);
</script>
<div class="flex flex-col items-center justify-center min-h-[70vh] p-6">
<div class="rounded-lg border border-gray-300 p-6 max-w-md w-full flex flex-col gap-6 text-center">
<div class="flex flex-col items-center gap-2">
<div class="text-red-600 bg-red-50 p-3 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<h1 class="text-2xl font-semibold text-gray-800">Access Denied</h1>
<p class="text-gray-600">You don't have permission to access the events section.</p>
</div>
<div class="flex flex-col gap-4">
{#if profile}
<p class="text-sm text-gray-500">
Your current role: <span class="font-semibold">{profile.section_position || 'Not assigned'}</span>
</p>
{#if profile.section}
<p class="text-sm text-gray-500">
Section: <span class="font-semibold">{profile.section.name}</span>
</p>
{/if}
{/if}
<p class="text-gray-600">
You need the <span class="font-semibold">events_manager</span> role to access this section.
Please contact your administrator for assistance.
</p>
</div>
<div class="flex flex-col gap-3">
<a href="/private/home" class="rounded-md px-4 py-2 bg-blue-600 text-white">
Go to Dashboard
</a>
<button
onclick={() => window.history.back()}
class="rounded-md px-4 py-2 border border-gray-300 text-gray-700"
aria-label="Go back"
>
Go Back
</button>
</div>
</div>
</div>

View File

@@ -10,7 +10,6 @@
import EmailSending from './components/EmailSending.svelte'; import EmailSending from './components/EmailSending.svelte';
import EmailResults from './components/EmailResults.svelte'; import EmailResults from './components/EmailResults.svelte';
import Statistics from './components/Statistics.svelte'; import Statistics from './components/Statistics.svelte';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import { toast } from '$lib/stores/toast.js'; import { toast } from '$lib/stores/toast.js';
let { data } = $props(); let { data } = $props();
@@ -48,6 +47,7 @@
let participantsLoading = $state(true); let participantsLoading = $state(true);
let syncingParticipants = $state(false); let syncingParticipants = $state(false);
let sendingEmails = $state(false); let sendingEmails = $state(false);
let updatingEmail = $state(false);
let emailProgress = $state({ sent: 0, total: 0 }); let emailProgress = $state({ sent: 0, total: 0 });
let emailResults = $state<{success: boolean, results: any[], summary: any} | null>(null); let emailResults = $state<{success: boolean, results: any[], summary: any} | null>(null);
@@ -74,10 +74,7 @@
event = eventData; event = eventData;
} catch (err) { } catch (err) {
console.error('Error loading event:', err); console.error('Error loading event:', err);
toast.add({ toast.error('Failed to load event');
message: 'Failed to load event',
type: 'error'
});
} finally { } finally {
loading = false; loading = false;
} }
@@ -98,25 +95,22 @@
participants = participantsData || []; participants = participantsData || [];
} catch (err) { } catch (err) {
console.error('Error loading participants:', err); console.error('Error loading participants:', err);
toast.add({ toast.error('Failed to load participants');
message: 'Failed to load participants',
type: 'error'
});
} finally { } finally {
participantsLoading = false; participantsLoading = false;
} }
} }
async function syncParticipants() { async function syncParticipants() {
if (!event || !event.sheet_id) return; if (!event || !event.sheet_id) {
toast.error('Cannot sync participants: No Google Sheet is connected to this event');
return;
}
// Check if user has Google authentication // Check if user has Google authentication
const refreshToken = localStorage.getItem('google_refresh_token'); const refreshToken = localStorage.getItem('google_refresh_token');
if (!refreshToken) { if (!refreshToken) {
toast.add({ toast.error('Please connect your Google account first to sync participants');
message: 'Please connect your Google account first to sync participants',
type: 'error'
});
return; return;
} }
@@ -183,12 +177,19 @@
// Reload participants // Reload participants
await loadParticipants(); await loadParticipants();
// Show success message with count of synced participants
const previousCount = participants.length;
const newCount = names.length;
const addedCount = Math.max(0, participants.length - previousCount);
toast.success(
`Successfully synced participants. ${newCount} entries processed, ${addedCount} new participants added.`,
5000
);
} catch (err) { } catch (err) {
console.error('Error syncing participants:', err); console.error('Error syncing participants:', err);
toast.add({ toast.error(`Failed to sync participants: ${err instanceof Error ? err.message : 'Unknown error'}`);
message: 'Failed to sync participants',
type: 'error'
});
} finally { } finally {
syncingParticipants = false; syncingParticipants = false;
} }
@@ -268,6 +269,42 @@
} }
} }
// For Email Template updating
async function handleEmailUpdate(eventId: string, subject: string, body: string) {
updatingEmail = true;
try {
// Call the email_modify RPC function
const { error } = await data.supabase.rpc('email_modify', {
p_event_id: eventId,
p_email_subject: subject,
p_email_body: body
});
if (error) throw error;
// Update the local event data on success
if (event) {
event.email_subject = subject;
event.email_body = body;
}
toast.add({
message: 'Email template updated successfully',
type: 'success'
});
} catch (err) {
console.error('Error updating email template:', err);
toast.add({
message: 'Failed to update email template',
type: 'error'
});
} finally {
updatingEmail = false;
}
}
function handleGoogleAuthSuccess() { function handleGoogleAuthSuccess() {
// Success handled by toast in the component // Success handled by toast in the component
} }
@@ -312,7 +349,12 @@ onSyncParticipants={syncParticipants}
/> />
</div> </div>
<EmailTemplate {event} {loading} /> <EmailTemplate
{event}
{loading}
{updatingEmail}
onUpdateEmail={handleEmailUpdate}
/>
<EmailSending <EmailSending
{loading} {loading}

View File

@@ -1,18 +1,104 @@
<script lang="ts"> <script lang="ts">
interface Event { interface Event {
id: string;
email_subject: string; email_subject: string;
email_body: string; email_body: string;
} }
let { event, loading } = $props<{ let {
event,
loading,
updatingEmail,
onUpdateEmail
} = $props<{
event: Event | null; event: Event | null;
loading: boolean; loading: boolean;
updatingEmail: boolean;
onUpdateEmail: (eventId: string, subject: string, body: string) => void;
}>(); }>();
// State for form
let isEditing = $state(false);
let emailSubject = $state('');
let emailBody = $state('');
// Update form values when event changes
$effect(() => {
if (event) {
emailSubject = event.email_subject;
emailBody = event.email_body;
}
});
// Toggle editing mode
function toggleEdit() {
isEditing = !isEditing;
// Reset form when exiting edit mode without saving
if (!isEditing && event) {
emailSubject = event.email_subject;
emailBody = event.email_body;
}
}
// Track the previous updatingEmail state to detect changes
let wasUpdating = $state(false);
// Save email template
function handleSave() {
if (!event) return;
onUpdateEmail(event.id, emailSubject, emailBody);
}
// Watch for updatingEmail changes to detect when operation completes
$effect(() => {
// Detect the transition from updating to not updating (operation completed)
if (wasUpdating && !updatingEmail) {
// If event data matches our form data, the update was successful
// Turn off editing mode after successful update
if (event && event.email_subject === emailSubject && event.email_body === emailBody) {
isEditing = false;
}
}
// Store current state for next comparison
wasUpdating = updatingEmail;
});
</script> </script>
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4"> <div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
<div class="mb-4"> <div class="mb-4 flex justify-between items-center">
<h2 class="text-xl font-semibold text-gray-900">Email Template</h2> <h2 class="text-xl font-semibold text-gray-900">Email Template</h2>
{#if !loading && event}
<div class="flex gap-3">
{#if isEditing}
<button
onclick={handleSave}
disabled={updatingEmail}
class="rounded bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Save email template"
>
{updatingEmail ? 'Saving...' : 'Save'}
</button>
<button
onclick={toggleEdit}
class="rounded bg-gray-300 px-4 py-2 text-gray-700 transition hover:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-50"
disabled={updatingEmail}
aria-label="Cancel editing"
>
Cancel
</button>
{:else}
<button
onclick={toggleEdit}
class="rounded bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={updatingEmail}
aria-label="Edit email template"
>
Edit Email
</button>
{/if}
</div>
{/if}
</div> </div>
{#if loading} {#if loading}
@@ -31,17 +117,34 @@
{:else if event} {:else if event}
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<span class="block mb-1 text-sm font-medium text-gray-700">Subject:</span> <label for="emailSubject" class="block mb-1 text-sm font-medium text-gray-700">Subject:</label>
<div class="bg-gray-50 p-3 rounded-lg border border-gray-200"> <input
<p class="text-sm text-gray-900">{event.email_subject}</p> id="emailSubject"
</div> type="text"
bind:value={emailSubject}
disabled={!isEditing || updatingEmail}
class="w-full px-3 py-2 border border-gray-300 rounded-lg {!isEditing ? 'bg-gray-50 cursor-default' : ''} focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-50"
/>
</div> </div>
<div> <div>
<span class="block mb-1 text-sm font-medium text-gray-700">Body:</span> <label for="emailBody" class="block mb-1 text-sm font-medium text-gray-700">Body:</label>
<div class="bg-gray-50 p-3 rounded-lg border border-gray-200 max-h-48 overflow-y-auto"> <textarea
<p class="text-sm whitespace-pre-wrap text-gray-900">{event.email_body}</p> id="emailBody"
</div> bind:value={emailBody}
rows="6"
disabled={!isEditing || updatingEmail}
class="w-full px-3 py-2 border border-gray-300 rounded-lg {!isEditing ? 'bg-gray-50 cursor-default' : ''} focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-50"
></textarea>
{#if isEditing}
<div class="mt-2 text-xs text-gray-500">
Template variables: <span class="font-mono bg-gray-100 px-1 rounded">&#123;name&#125;</span>,
<span class="font-mono bg-gray-100 px-1 rounded">&#123;surname&#125;</span>
</div>
{/if}
</div> </div>
<!-- Save button moved to the header -->
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { toast } from '$lib/stores/toast.js';
interface Participant { interface Participant {
id: string; id: string;
name: string; name: string;
@@ -28,6 +30,16 @@
syncingParticipants: boolean; syncingParticipants: boolean;
onSyncParticipants: () => void; onSyncParticipants: () => void;
}>(); }>();
// Handle sync participants with toast notifications
function handleSyncParticipants() {
// Show initial notification about sync starting
toast.info('Starting participant synchronization...', 2000);
// Call the parent component's sync function
onSyncParticipants();
}
</script> </script>
<div class="mb-4 rounded-lg border border-gray-300 bg-white p-6"> <div class="mb-4 rounded-lg border border-gray-300 bg-white p-6">

View File

@@ -1,22 +0,0 @@
// src/routes/my-page/+page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
// get the logged-in user
const { data: { user }, error: authError } = await locals.supabase.auth.getUser();
const { data: user_profile, error: profileError } = await locals.supabase.from('profiles').select('*, section:sections (id, name)').eq('id', user?.id).single();
if (authError) {
console.error('Supabase auth error:', authError);
throw new Error('Could not get user');
}
if (profileError) {
console.error('Supabase profile error:', profileError);
throw new Error('Could not get user profile');
}
return { user, user_profile };
};

View File

@@ -1,51 +1,76 @@
<script lang="ts"> <script lang="ts">
import type { User } from '@supabase/supabase-js'; let { data } = $props();
export let data: {
user: User | null,
user_profile: any | null
};
</script> </script>
<h1 class="mt-2 mb-4 text-center text-2xl font-bold">User Profile</h1> <div class="p-4 sm:p-6">
<h1 class="mb-6 text-2xl font-bold text-gray-800">User Dashboard</h1>
<div class="mb-4 rounded border border-gray-300 bg-white p-6"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="flex flex-col gap-2"> <!-- Left Column: User Profile -->
<div class="flex items-center gap-3 mb-4"> <div class="lg:col-span-1">
<div class="h-12 w-12 rounded-full bg-gray-200 flex items-center justify-center text-xl font-bold text-gray-600"> <div class="flex h-full flex-col rounded-lg border border-gray-300 bg-white p-6">
{data.user?.user_metadata.display_name?.[0] ?? "U"} <div class="flex flex-grow flex-col items-center text-center">
</div> <div
<div> class="mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-4xl font-bold text-gray-600"
<span class="text-lg font-semibold text-gray-800">{data.user?.user_metadata.display_name}</span> >
<div class="text-sm text-gray-500">{data.user?.email}</div> {data.profile?.display_name?.[0]?.toUpperCase() ?? 'U'}
</div> </div>
</div> <h2 class="text-xl font-semibold text-gray-900">{data.profile?.display_name}</h2>
<div class="flex flex-col gap-1"> <p class="text-sm text-gray-500">{data.user?.email}</p>
<div> </div>
<span class="font-medium text-gray-700">Section:</span> <div class="mt-6 text-center">
<span class="text-gray-900">{data.user_profile?.section.name ?? "N/A"}</span> <a
</div> href="/auth/signout"
<div> class="text-sm text-red-500 transition hover:text-red-700 hover:underline"
<span class="font-medium text-gray-700">Position:</span> >Sign Out</a
<span class="text-gray-900">{data.user_profile?.section_position ?? "N/A"}</span> >
</div> </div>
</div> </div>
<h2 class="text-lg mb-2 mt-4">User guide</h2> </div>
<p class="text-gray-700 text-sm leading-relaxed">
To scan a QR code, head over to Scanner in the top right corner. Click on Start scanning and allow camera permissions. <!-- Right Column: Information -->
If you close and open your browser and your camera is stuck, simply refresh the page or click Stop scanning and then Start scanning again. <div class="space-y-6 lg:col-span-2">
When you scan a QR code, a request is sent to the server to get the user's personal information and to mark their tickets as scanned. <!-- Role Information -->
</p> <div class="rounded-lg border border-gray-300 bg-white p-6">
<h2 class="text-lg mb-2 mt-4">Administrator guide</h2> <h2 class="mb-4 text-lg font-semibold text-gray-900">Your Role</h2>
<p class="text-gray-700 text-sm leading-relaxed"> <dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
You can view events <div class="sm:col-span-1">
</p> <dt class="text-sm font-medium text-gray-500">Section</dt>
</div> <dd class="mt-1 text-sm font-semibold text-gray-900">
{data.profile?.section?.name ?? 'N/A'}
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500">Position</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">
{data.profile?.section_position ?? 'N/A'}
</dd>
</div>
</dl>
</div>
<!-- User Guide -->
<div class="rounded-lg border border-gray-300 bg-white p-6">
<h2 class="mb-2 text-lg font-semibold text-gray-900">User Guide</h2>
<p class="text-sm leading-relaxed text-gray-700">
To scan a QR code, head over to <strong>Scanner</strong> in the top right corner. Click
on "Start Scanning" and allow camera permissions. If your camera gets stuck, simply
refresh the page or click "Stop Scanning" and then "Start Scanning" again. When you scan
a QR code, the participant's ticket is automatically marked as scanned.
</p>
</div>
<!-- Events Manager Guide -->
{#if data.profile?.section_position === 'events_manager'}
<div class="rounded-lg border border-gray-300 bg-white p-6">
<h2 class="mb-2 text-lg font-semibold text-gray-900">Events Manager Guide</h2>
<p class="text-sm leading-relaxed text-gray-700">
As an Events Manager, you have access to the <strong>Events</strong> section. Here you
can create new events, manage participants by syncing with Google Sheets, send email
invitations with QR codes, and view event statistics.
</p>
</div>
{/if}
</div>
</div>
</div> </div>
<a
href="/auth/signout"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-red-500 hover:bg-red-600 text-white font-semibold py-3 px-8 rounded-full shadow-none border border-gray-300 transition"
>
Sign out
</a>