This commit is contained in:
user
2026-03-27 20:06:38 +02:00
commit 8c45efc92e
544 changed files with 33060 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
import {
banUserSchema,
checkUsernameSchema,
ensureAccountExistsSchema,
rotatePasswordSchema,
} from "@pkg/logic/domains/user/data";
import {
getFlowExecCtxForRemoteFuncs,
unauthorized,
} from "$lib/core/server.utils";
import { getUserController } from "@pkg/logic/domains/user/controller";
import { command, getRequestEvent, query } from "$app/server";
import * as v from "valibot";
const uc = getUserController();
export const getMyInfoSQ = query(async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.getUserInfo(fctx, fctx.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const getUserInfoByIdSQ = query(
v.object({ userId: v.string() }),
async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.getUserInfo(fctx, input.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const ensureAccountExistsSC = command(
ensureAccountExistsSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.ensureAccountExists(fctx, payload.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const checkUsernameSC = command(checkUsernameSchema, async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.isUsernameAvailable(fctx, payload.username);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const update2faVerifiedSC = command(
v.object({ userId: v.string() }),
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.updateLastVerified2FaAtToNow(fctx, payload.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const banUserSC = command(banUserSchema, async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.banUser(
fctx,
payload.userId,
payload.reason,
payload.banExpiresAt,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const isUserBannedSQ = query(
v.object({ userId: v.string() }),
async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.isUserBanned(fctx, input.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const getBanInfoSQ = query(
v.object({ userId: v.string() }),
async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.getBanInfo(fctx, input.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const rotatePasswordSC = command(
rotatePasswordSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.rotatePassword(
fctx,
payload.userId,
payload.password,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);

View File

@@ -0,0 +1,153 @@
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import type { User } from "@pkg/logic/domains/user/data";
import { user as userStore } from "$lib/global.stores";
import { rotatePasswordSC } from "./account.remote";
import { authClient } from "$lib/auth.client";
import type { Err } from "@pkg/result";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
class AccountViewModel {
loading = $state(false);
passwordLoading = $state(false);
errorMessage = $state<string | null>(null);
async updateProfilePicture(imagePath: string): Promise<boolean> {
const result = await ResultAsync.fromPromise(
authClient.updateUser({ image: imagePath }),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to update profile picture",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
).andThen((response) => {
if (response.error) {
return errAsync({
code: "API_ERROR",
message:
response.error.message ??
"Failed to update profile picture",
description:
response.error.statusText ?? "Please try again later",
detail: response.error.statusText ?? "Unknown error",
});
}
return okAsync(response.data);
});
return result.match(
() => {
toast.success("Profile picture updated");
return true;
},
(error) => {
this.errorMessage =
error.message ?? "Failed to update profile picture";
toast.error(this.errorMessage, {
description: error.description,
});
return false;
},
);
}
async updateProfile(userData: {
name: string;
username: string;
}): Promise<User | null> {
this.loading = true;
this.errorMessage = null;
const result = await ResultAsync.fromPromise(
authClient.updateUser({
displayUsername: userData.username,
username: userData.username,
name: userData.name,
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to update profile",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
).andThen((response) => {
if (response.error) {
return errAsync({
code: "API_ERROR",
message:
response.error.message ?? "Failed to update profile",
description:
response.error.statusText ?? "Please try again later",
detail: response.error.statusText ?? "Unknown error",
});
}
return okAsync(response.data);
});
const user = result.match(
(data) => {
toast.success("Profile updated successfully");
window.location.reload();
return (data as any)?.user as User | null;
},
(error) => {
this.errorMessage = error.message ?? "Failed to update profile";
toast.error(this.errorMessage, {
description: error.description,
});
return null;
},
);
this.loading = false;
return user;
}
async changePassword(password: string): Promise<boolean> {
this.passwordLoading = true;
this.errorMessage = null;
const currentUser = get(userStore);
if (!currentUser?.id) {
this.passwordLoading = false;
this.errorMessage = "User not found";
toast.error(this.errorMessage);
return false;
}
const result = await ResultAsync.fromPromise(
rotatePasswordSC({ userId: currentUser.id, password }),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to change password",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
).andThen((apiResult: any) => {
if (apiResult?.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult?.data);
});
const success = result.match(
() => {
toast.success("Password updated successfully");
return true;
},
(error) => {
this.errorMessage =
(error.message as string) ?? "Failed to change password";
toast.error(this.errorMessage, {
description: error.description,
});
return false;
},
);
this.passwordLoading = false;
return success;
}
}
export const accountVM = new AccountViewModel();

View File

@@ -0,0 +1,182 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { cn } from "$lib/utils";
import {
Clock,
Globe,
Laptop,
Loader2,
LogOut,
Smartphone,
} from "@lucide/svelte";
import { onMount } from "svelte";
import { sessionsVM } from "./sessions.vm.svelte";
onMount(() => {
setTimeout(() => {
sessionsVM.fetchActiveSessions();
}, 500);
});
function getDeviceIcon(userAgent: string | undefined | null) {
if (!userAgent) {
return Globe;
}
if (
userAgent.toLowerCase().includes("mobile") ||
userAgent.toLowerCase().includes("android") ||
userAgent.toLowerCase().includes("iphone")
) {
return Smartphone;
}
return Laptop;
}
function extractInfoFromUA(userAgent: string) {
const osRegex = /(iPhone|iPad|Macintosh|Windows|Android)/;
const browserRegex = /(Chrome|Safari|Firefox|Opera|Edge|Trident)/;
const osMatch = userAgent.match(osRegex);
const browserMatch = userAgent.match(browserRegex);
const defBro = "Unknown Browser";
const defOS = "Unknown OS";
if (osMatch && osMatch.length > 0) {
return {
os: osMatch[0] || defOS,
browser: browserMatch ? browserMatch[0] || defBro : defBro,
};
} else {
return { os: defOS, browser: defBro };
}
}
</script>
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<Icon icon={Globe} cls="h-5 w-5 text-primary" />
<Card.Title>Active Sessions</Card.Title>
</div>
<Card.Description>
Manage and monitor your active login sessions across devices.
</Card.Description>
</Card.Header>
<Card.Content>
<div class="space-y-6">
{#if sessionsVM.isLoading && sessionsVM.activeSessions.length === 0}
<div class="flex justify-center py-6">
<Icon
icon={Loader2}
cls="h-8 w-8 animate-spin text-muted-foreground"
/>
</div>
{:else if sessionsVM.activeSessions.length > 0}
<div class="space-y-4">
{#each sessionsVM.activeSessions as session}
{@const { os, browser } = extractInfoFromUA(
session.userAgent ?? "",
)}
<div
class={cn(
"flex flex-col gap-3 rounded-md border p-4 shadow-sm",
session.isCurrent && "bg-primary/10",
)}
>
<div class="flex items-center gap-3">
<div class="bg-primary/10 rounded-full p-2">
<Icon
icon={getDeviceIcon(session.userAgent)}
cls="h-5 w-5 text-primary"
/>
</div>
<div>
<div class="flex items-center gap-2">
<h4 class="font-medium">
{browser || "Unknown Browser"}
on
{os || "Unknown OS"}
</h4>
{#if session.isCurrent}
<span
class="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs font-medium"
>
Current Session
</span>
{/if}
</div>
<div class="text-muted-foreground text-sm">
{session.ipAddress || "Unknown IP"}
</div>
<div
class="text-muted-foreground flex items-center gap-1 text-xs"
>
<Icon icon={Clock} cls="h-3 w-3" />
<span>
Created{" "}
{sessionsVM.formatRelativeTime(
session.createdAt.getTime(),
)}
</span>
</div>
</div>
</div>
{#if !session.isCurrent}
<Button
variant="outline"
size="sm"
class="self-end"
onclick={() =>
sessionsVM.terminateSession(session.id)}
disabled={sessionsVM.isLoading}
>
{#if sessionsVM.isLoading}
<Icon
icon={Loader2}
cls="mr-2 h-4 w-4 animate-spin"
/>
{:else}
<Icon
icon={LogOut}
cls="mr-2 h-4 w-4 text-destructive"
/>
End Session
{/if}
</Button>
{/if}
</div>
{/each}
</div>
{#if sessionsVM.activeSessions.filter((s) => !s.isCurrent).length > 0}
<Button
variant="outline"
class="w-full"
onclick={() => sessionsVM.terminateAllOtherSessions()}
disabled={sessionsVM.isLoading}
>
{#if sessionsVM.isLoading}
<Icon
icon={Loader2}
cls="mr-2 h-4 w-4 animate-spin"
/>
{:else}
<Icon icon={LogOut} cls="h-4 w-4 mr-2" />
Sign out of all other sessions
{/if}
</Button>
{/if}
{:else}
<div class="p-6 text-center">
<p class="text-muted-foreground">
No active sessions found. This is unusual and might
indicate a problem.
</p>
</div>
{/if}
</div>
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,209 @@
import type { ModifiedSession } from "@pkg/logic/domains/user/data";
import { authClient } from "$lib/auth.client";
import { toast } from "svelte-sonner";
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import type { Err } from "@pkg/result";
class SessionsViewModel {
session: ModifiedSession | undefined = $state(undefined);
activeSessions = $state<ModifiedSession[]>([]);
isLoading = $state(false);
errorMessage = $state<string | null>(null);
async setCurrentSession(s: ModifiedSession) {
this.session = s;
}
async fetchActiveSessions() {
this.isLoading = true;
this.errorMessage = null;
const result = await ResultAsync.fromPromise(
authClient.listSessions(),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to fetch active sessions",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (response.error) {
return errAsync({
code: "API_ERROR",
message: "Failed to fetch active sessions",
description: response.error.message ?? "Unknown error",
detail: response.error.statusText ?? "Unknown error",
});
}
return okAsync(response.data ?? []);
});
const sessions = result.match(
(data) => {
this.activeSessions = data.map((session: ModifiedSession) => ({
...session,
isCurrent: session.id === this.session?.id,
}));
return this.activeSessions;
},
(error) => {
this.errorMessage = error.message ?? "Failed to fetch active sessions";
toast.error("Failed to fetch active sessions", {
description: error.description,
});
return [];
},
);
this.isLoading = false;
return sessions;
}
async terminateSession(sessionId: string) {
this.isLoading = true;
this.errorMessage = null;
const result = await ResultAsync.fromPromise(
authClient.revokeSession({
token: sessionId,
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to terminate session",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (response.error) {
return errAsync({
code: "API_ERROR",
message: "Failed to terminate session",
description: response.error.message ?? "Unknown error",
detail: response.error.statusText ?? "Unknown error",
});
}
return okAsync(response.data);
});
result.match(
() => {
this.activeSessions = this.activeSessions.filter(
(session) => session.id !== sessionId,
);
toast.success("Session terminated");
},
(error) => {
this.errorMessage = error.message ?? "Failed to terminate session";
toast.error("Failed to terminate session", {
description: error.description,
});
},
);
this.isLoading = false;
}
async terminateAllOtherSessions() {
this.isLoading = true;
this.errorMessage = null;
const result = await ResultAsync.fromPromise(
authClient.revokeOtherSessions(),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to terminate other sessions",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (response.error) {
return errAsync({
code: "API_ERROR",
message: "Failed to terminate other sessions",
description: response.error.message ?? "Unknown error",
detail: response.error.statusText ?? "Unknown error",
});
}
return okAsync(response.data);
});
result.match(
() => {
this.activeSessions = this.activeSessions.filter(
// @ts-ignore
(session) => session.isCurrent,
);
toast.success("All other sessions terminated");
},
(error) => {
this.errorMessage =
error.message ?? "Failed to terminate other sessions";
toast.error("Failed to terminate other sessions", {
description: error.description,
});
},
);
this.isLoading = false;
}
async logout(skipToast = false) {
const result = await ResultAsync.fromPromise(
authClient.signOut(),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to log out",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
);
result.match(
() => {
if (!skipToast) {
toast("Logged out successfully, redirecting...");
}
setTimeout(() => {
window.location.href = "/auth/login";
}, 500);
},
(error) => {
toast.error("Failed to log out", {
description: error.description,
});
},
);
}
formatRelativeTime(timestamp: string | number): string {
const date = new Date(timestamp);
const now = new Date();
const diffInSeconds = Math.floor(
(now.getTime() - date.getTime()) / 1000,
);
if (diffInSeconds < 60) {
return "just now";
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `${hours} hour${hours > 1 ? "s" : ""} ago`;
} else {
const days = Math.floor(diffInSeconds / 86400);
return `${days} day${days > 1 ? "s" : ""} ago`;
}
}
reset() {
this.activeSessions = [];
this.isLoading = false;
this.errorMessage = null;
}
}
export const sessionsVM = new SessionsViewModel();

View File

@@ -0,0 +1,235 @@
import type {
ClientNotificationFilters,
ClientPaginationState,
Notifications,
} from "@pkg/logic/domains/notifications/data";
import {
archiveSC,
deleteNotificationsSC,
getNotificationsSQ,
getUnreadCountSQ,
markAllReadSC,
markReadSC,
markUnreadSC,
unarchiveSC,
} from "./notifications.remote";
import { user } from "$lib/global.stores";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
class NotificationViewModel {
notifications = $state([] as Notifications);
loading = $state(false);
selectedIds = $state(new Set<number>());
pagination = $state<ClientPaginationState>({
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
sortBy: "createdAt",
sortOrder: "desc",
});
filters = $state<ClientNotificationFilters>({
userId: get(user)?.id!,
isArchived: false,
});
unreadCount = $state(0);
private getFetchQueryInput() {
return {
filters: {
userId: this.filters.userId,
isRead: this.filters.isRead,
isArchived: this.filters.isArchived,
type: this.filters.type,
category: this.filters.category,
priority: this.filters.priority,
search: this.filters.search,
},
pagination: {
page: this.pagination.page,
pageSize: this.pagination.pageSize,
sortBy: this.pagination.sortBy,
sortOrder: this.pagination.sortOrder,
},
};
}
private async runCommand(
fn: (payload: { notificationIds: number[] }) => Promise<any>,
notificationIds: number[],
errorMessage: string,
after: Array<() => Promise<void>>,
) {
try {
const result = await fn({ notificationIds });
if (result?.error) {
toast.error(result.error.message || errorMessage, {
description: result.error.description || "Please try again later",
});
return;
}
for (const action of after) {
await action();
}
} catch (error) {
toast.error(errorMessage, {
description:
error instanceof Error ? error.message : "Please try again later",
});
}
}
async fetchNotifications() {
this.loading = true;
try {
const result = await getNotificationsSQ(this.getFetchQueryInput());
if (result?.error || !result?.data) {
toast.error(
result?.error?.message || "Failed to fetch notifications",
{
description:
result?.error?.description || "Please try again later",
},
);
return;
}
this.notifications = result.data.data as Notifications;
this.pagination.total = result.data.total;
this.pagination.totalPages = result.data.totalPages;
} catch (error) {
toast.error("Failed to fetch notifications", {
description:
error instanceof Error ? error.message : "Please try again later",
});
} finally {
this.loading = false;
}
}
async markAsRead(notificationIds: number[]) {
await this.runCommand(markReadSC, notificationIds, "Failed to mark as read", [
() => this.fetchNotifications(),
() => this.fetchUnreadCount(),
]);
}
async markAsUnread(notificationIds: number[]) {
await this.runCommand(
markUnreadSC,
notificationIds,
"Failed to mark as unread",
[() => this.fetchNotifications(), () => this.fetchUnreadCount()],
);
}
async archive(notificationIds: number[]) {
await this.runCommand(archiveSC, notificationIds, "Failed to archive", [
() => this.fetchNotifications(),
]);
}
async unarchive(notificationIds: number[]) {
await this.runCommand(unarchiveSC, notificationIds, "Failed to unarchive", [
() => this.fetchNotifications(),
]);
}
async deleteNotifications(notificationIds: number[]) {
await this.runCommand(
deleteNotificationsSC,
notificationIds,
"Failed to delete",
[() => this.fetchNotifications(), () => this.fetchUnreadCount()],
);
}
async markAllAsRead() {
try {
const result = await markAllReadSC({});
if (result?.error) {
toast.error(result.error.message || "Failed to mark all as read", {
description: result.error.description || "Please try again later",
});
return;
}
await this.fetchNotifications();
await this.fetchUnreadCount();
} catch (error) {
toast.error("Failed to mark all as read", {
description:
error instanceof Error ? error.message : "Please try again later",
});
}
}
async fetchUnreadCount() {
try {
const result = await getUnreadCountSQ();
if (result?.error) {
return;
}
if (result?.data !== undefined && result?.data !== null) {
this.unreadCount = result.data as number;
}
} catch {
// Intentionally silent - unread count is non-critical UI data.
}
}
toggleSelection(id: number) {
if (this.selectedIds.has(id)) {
this.selectedIds.delete(id);
} else {
this.selectedIds.add(id);
}
}
selectAll() {
this.notifications.forEach((n) => this.selectedIds.add(n.id));
}
clearSelection() {
this.selectedIds.clear();
}
goToPage(page: number) {
this.pagination.page = page;
this.fetchNotifications();
}
setPageSize(pageSize: number) {
this.pagination.pageSize = pageSize;
this.pagination.page = 1;
this.fetchNotifications();
}
setSorting(
sortBy: ClientPaginationState["sortBy"],
sortOrder: ClientPaginationState["sortOrder"],
) {
this.pagination.sortBy = sortBy;
this.pagination.sortOrder = sortOrder;
this.pagination.page = 1;
this.fetchNotifications();
}
setFilters(newFilters: Partial<ClientNotificationFilters>) {
this.filters = { ...this.filters, ...newFilters };
this.pagination.page = 1;
this.fetchNotifications();
}
clearFilters() {
this.filters = { userId: get(user)?.id!, isArchived: false };
this.pagination.page = 1;
this.fetchNotifications();
}
}
export const notificationViewModel = new NotificationViewModel();

View File

@@ -0,0 +1,566 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import { Button, buttonVariants } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { Checkbox } from "$lib/components/ui/checkbox";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { Input } from "$lib/components/ui/input";
import * as Table from "$lib/components/ui/table";
import { cn } from "$lib/utils";
import {
AlertCircle,
Archive,
Bell,
Check,
CheckCircle,
ChevronLeft,
ChevronRight,
Clock,
Info,
ListFilter,
Loader2,
MoreHorizontal,
Search,
Trash2,
} from "@lucide/svelte";
import { onMount } from "svelte";
import { notificationViewModel } from "./notification.vm.svelte";
onMount(() => {
setTimeout(() => {
notificationViewModel.fetchNotifications();
notificationViewModel.fetchUnreadCount();
}, 500);
});
function getPriorityColor(priority: string) {
switch (priority.toLowerCase()) {
case "high":
return "text-red-600 bg-red-50";
case "medium":
return "text-yellow-600 bg-yellow-50";
case "low":
return "text-emerald-600 dark:text-emerald-400 bg-green-50";
default:
return "text-gray-600 bg-gray-50";
}
}
function getTypeIcon(type: string) {
switch (type.toLowerCase()) {
case "info":
return Info;
case "warning":
return AlertCircle;
case "success":
return CheckCircle;
case "error":
return AlertCircle;
default:
return Bell;
}
}
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) {
return "just now";
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `${hours} hour${hours > 1 ? "s" : ""} ago`;
} else {
const days = Math.floor(diffInSeconds / 86400);
return `${days} day${days > 1 ? "s" : ""} ago`;
}
}
function handleSelectAll(checked: boolean) {
if (checked) {
notificationViewModel.selectAll();
} else {
notificationViewModel.clearSelection();
}
}
function handleRowSelect(notificationId: number, checked: boolean) {
notificationViewModel.toggleSelection(notificationId);
}
async function handleBulkMarkAsRead() {
if (notificationViewModel.selectedIds.size > 0) {
await notificationViewModel.markAsRead(
Array.from(notificationViewModel.selectedIds),
);
notificationViewModel.clearSelection();
}
}
async function handleBulkMarkAsUnread() {
if (notificationViewModel.selectedIds.size > 0) {
await notificationViewModel.markAsUnread(
Array.from(notificationViewModel.selectedIds),
);
notificationViewModel.clearSelection();
}
}
async function handleBulkArchive() {
if (notificationViewModel.selectedIds.size > 0) {
await notificationViewModel.archive(
Array.from(notificationViewModel.selectedIds),
);
notificationViewModel.clearSelection();
}
}
async function handleBulkDelete() {
if (notificationViewModel.selectedIds.size > 0) {
await notificationViewModel.deleteNotifications(
Array.from(notificationViewModel.selectedIds),
);
notificationViewModel.clearSelection();
}
}
async function handleSingleAction(notificationId: number, action: string) {
switch (action) {
case "mark-read":
await notificationViewModel.markAsRead([notificationId]);
break;
case "mark-unread":
await notificationViewModel.markAsUnread([notificationId]);
break;
case "archive":
await notificationViewModel.archive([notificationId]);
break;
case "delete":
await notificationViewModel.deleteNotifications([notificationId]);
break;
}
}
function handleSearch(event: Event) {
const target = event.target as HTMLInputElement;
const searchTerm = target.value;
notificationViewModel.setFilters({ search: searchTerm || undefined });
}
function handleFilterChange(key: string, value: any) {
notificationViewModel.setFilters({ [key]: value });
}
// Reactive computed values
let isAllSelected = $derived(
notificationViewModel.notifications.length > 0 &&
notificationViewModel.notifications.every((n) =>
notificationViewModel.selectedIds.has(n.id),
),
);
let isIndeterminate = $derived(
notificationViewModel.selectedIds.size > 0 && !isAllSelected,
);
let hasSelection = $derived(notificationViewModel.selectedIds.size > 0);
</script>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon={Bell} cls="h-5 w-5 text-primary" />
<Card.Title>Notifications</Card.Title>
{#if notificationViewModel.unreadCount > 0}
<span
class="bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs font-medium"
>
{notificationViewModel.unreadCount} unread
</span>
{/if}
</div>
{#if hasSelection}
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{notificationViewModel.selectedIds.size} selected
</span>
<Button
variant="outline"
size="sm"
onclick={handleBulkMarkAsRead}
>
<Icon icon={Check} cls="h-4 w-4 mr-2" />
Mark as Read
</Button>
<Button
variant="outline"
size="sm"
onclick={handleBulkArchive}
>
<Icon icon={Archive} cls="h-4 w-4 mr-2" />
Archive
</Button>
<Button
variant="outline"
size="sm"
onclick={handleBulkDelete}
>
<Icon icon={Trash2} cls="h-4 w-4 mr-2" />
Delete
</Button>
</div>
{:else}
<Button
variant="outline"
size="sm"
onclick={() => notificationViewModel.markAllAsRead()}
disabled={notificationViewModel.unreadCount === 0}
>
Mark All as Read
</Button>
{/if}
</div>
<div
class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<!-- Search -->
<div class="relative max-w-sm flex-1">
<Icon
icon={Search}
cls="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search notifications..."
class="pl-10"
value={notificationViewModel.filters.search || ""}
oninput={handleSearch}
/>
</div>
<!-- Filters -->
<div class="flex items-center gap-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class={buttonVariants({ variant: "outline", size: "sm" })}
>
<Icon icon={ListFilter} cls="h-4 w-4 mr-2" />
Filters
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item
onclick={() =>
handleFilterChange("isRead", undefined)}
>
All
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => handleFilterChange("isRead", false)}
>
Unread Only
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => handleFilterChange("isRead", true)}
>
Read Only
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={() =>
handleFilterChange("isArchived", false)}
>
Active
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => handleFilterChange("isArchived", true)}
>
Archived
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
</Card.Header>
<Card.Content>
{#if notificationViewModel.loading && notificationViewModel.notifications.length === 0}
<div class="flex justify-center py-8">
<Icon
icon={Loader2}
cls="h-8 w-8 animate-spin text-muted-foreground"
/>
</div>
{:else if notificationViewModel.notifications.length === 0}
<div class="py-8 text-center">
<Icon
icon={Bell}
cls="h-12 w-12 text-muted-foreground mx-auto mb-4"
/>
<h3 class="mb-2 text-lg font-medium">No notifications</h3>
<p class="text-muted-foreground">
{notificationViewModel.filters.search
? "No notifications match your search."
: "You're all caught up!"}
</p>
</div>
{:else}
<div class="space-y-4">
<!-- Table -->
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-12">
<Checkbox
checked={isAllSelected}
indeterminate={isIndeterminate}
onCheckedChange={handleSelectAll}
/>
</Table.Head>
<Table.Head class="w-12"></Table.Head>
<Table.Head>Notification</Table.Head>
<Table.Head class="w-24">Priority</Table.Head>
<Table.Head class="w-32">Time</Table.Head>
<Table.Head class="w-12"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each notificationViewModel.notifications as notification (notification.id)}
<Table.Row
class={cn(
"hover:bg-muted/50 cursor-pointer",
!notification.isRead &&
"bg-primary/5 border-l-primary border-l-4",
)}
>
<Table.Cell>
<Checkbox
checked={notificationViewModel.selectedIds.has(
notification.id,
)}
onCheckedChange={(checked: boolean) =>
handleRowSelect(
notification.id,
checked,
)}
/>
</Table.Cell>
<Table.Cell>
<div
class={cn(
"rounded-full p-2",
notification.isRead
? "bg-muted"
: "bg-primary/10",
)}
>
<Icon
icon={getTypeIcon(notification.type)}
cls={cn(
"h-4 w-4",
notification.isRead
? "text-muted-foreground"
: "text-primary",
)}
/>
</div>
</Table.Cell>
<Table.Cell>
<div class="space-y-1">
<p
class={cn(
"font-medium",
notification.isRead
? "text-muted-foreground"
: "text-foreground",
)}
>
{notification.title}
</p>
<p
class="text-muted-foreground line-clamp-2 text-sm"
>
{notification.body}
</p>
{#if notification.category}
<span
class="bg-secondary text-secondary-foreground inline-flex items-center rounded-full px-2 py-1 text-xs font-medium"
>
{notification.category}
</span>
{/if}
</div>
</Table.Cell>
<Table.Cell>
<span
class={cn(
"inline-flex items-center rounded-full px-2 py-1 text-xs font-medium",
getPriorityColor(
notification.priority,
),
)}
>
{notification.priority}
</span>
</Table.Cell>
<Table.Cell>
<div
class="text-muted-foreground flex items-center gap-1 text-sm"
>
<Icon icon={Clock} cls="h-3 w-3" />
<span
>{formatRelativeTime(
notification.sentAt,
)}</span
>
</div>
</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class={buttonVariants({
variant: "ghost",
size: "sm",
})}
>
<Icon
icon={MoreHorizontal}
cls="h-4 w-4"
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{#if notification.isRead}
<DropdownMenu.Item
onclick={() =>
handleSingleAction(
notification.id,
"mark-unread",
)}
>
Mark as Unread
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item
onclick={() =>
handleSingleAction(
notification.id,
"mark-read",
)}
>
Mark as Read
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item
onclick={() =>
handleSingleAction(
notification.id,
"archive",
)}
>
Archive
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={() =>
handleSingleAction(
notification.id,
"delete",
)}
class="text-destructive"
>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<!-- Pagination -->
{#if notificationViewModel.pagination.totalPages > 1}
<div class="flex items-center justify-between">
<div class="text-muted-foreground text-sm">
Showing {(notificationViewModel.pagination.page - 1) *
notificationViewModel.pagination.pageSize +
1} to {Math.min(
notificationViewModel.pagination.page *
notificationViewModel.pagination.pageSize,
notificationViewModel.pagination.total,
)} of {notificationViewModel.pagination.total} notifications
</div>
<div class="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onclick={() =>
notificationViewModel.goToPage(
notificationViewModel.pagination.page - 1,
)}
disabled={notificationViewModel.pagination
.page === 1}
>
<Icon icon={ChevronLeft} cls="h-4 w-4" />
Previous
</Button>
<div class="flex items-center space-x-1">
{#each Array.from( { length: Math.min(5, notificationViewModel.pagination.totalPages) }, (_, i) => {
const startPage = Math.max(1, notificationViewModel.pagination.page - 2);
return startPage + i;
}, ) as page}
{#if page <= notificationViewModel.pagination.totalPages}
<Button
variant={page ===
notificationViewModel.pagination.page
? "default"
: "outline"}
size="sm"
onclick={() =>
notificationViewModel.goToPage(
page,
)}
>
{page}
</Button>
{/if}
{/each}
</div>
<Button
variant="outline"
size="sm"
onclick={() =>
notificationViewModel.goToPage(
notificationViewModel.pagination.page + 1,
)}
disabled={notificationViewModel.pagination
.page ===
notificationViewModel.pagination.totalPages}
>
Next
<Icon icon={ChevronRight} cls="h-4 w-4" />
</Button>
</div>
</div>
{/if}
</div>
{/if}
</Card.Content>
</Card.Root>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,155 @@
import {
bulkNotificationIdsSchema,
getNotificationsSchema,
} from "@pkg/logic/domains/notifications/data";
import { getNotificationController } from "@pkg/logic/domains/notifications/controller";
import {
getFlowExecCtxForRemoteFuncs,
unauthorized,
} from "$lib/core/server.utils";
import { command, getRequestEvent, query } from "$app/server";
import * as v from "valibot";
const nc = getNotificationController();
export const getNotificationsSQ = query(
getNotificationsSchema,
async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.getNotifications(
fctx,
{ ...input.filters, userId: fctx.userId },
input.pagination,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const markReadSC = command(
bulkNotificationIdsSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.markAsRead(
fctx,
[...payload.notificationIds],
fctx.userId,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const markUnreadSC = command(
bulkNotificationIdsSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.markAsUnread(
fctx,
[...payload.notificationIds],
fctx.userId,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const archiveSC = command(bulkNotificationIdsSchema, async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.archive(
fctx,
[...payload.notificationIds],
fctx.userId,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const unarchiveSC = command(
bulkNotificationIdsSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.unarchive(
fctx,
[...payload.notificationIds],
fctx.userId,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const deleteNotificationsSC = command(
bulkNotificationIdsSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.deleteNotifications(
fctx,
[...payload.notificationIds],
fctx.userId,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const markAllReadSC = command(v.object({}), async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.markAllAsRead(fctx, fctx.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const getUnreadCountSQ = query(async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await nc.getUnreadCount(fctx, fctx.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});

View File

@@ -0,0 +1,160 @@
import { session, user } from "$lib/global.stores";
import { authClient } from "$lib/auth.client";
import {
startVerificationSessionSC,
verifySessionCodeSC,
} from "$lib/domains/security/twofa.remote";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { page } from "$app/state";
class TwoFactorVerifyViewModel {
verifying = $state(false);
verificationCode = $state("");
verificationToken = $state<string | null>(null);
errorMessage = $state<string | null>(null);
startingVerification = $state(false);
async startVerification() {
this.startingVerification = true;
this.errorMessage = null;
const currentUser = get(user);
const currentSession = get(session);
const uid = currentUser?.id;
const sid = currentSession?.id;
if (!uid || !sid) {
this.errorMessage = "No active session found";
toast.error("Failed to start verification", {
description: this.errorMessage,
});
this.startingVerification = false;
return;
}
try {
const result = await startVerificationSessionSC({
userId: uid,
sessionId: sid,
});
if (result?.error || !result?.data?.verificationToken) {
this.errorMessage =
result?.error?.message || "Failed to start verification";
toast.error("Failed to start verification", {
description: this.errorMessage,
});
return;
}
this.verificationToken = result.data.verificationToken;
} catch (error) {
this.errorMessage = "Failed to start verification";
toast.error("Failed to start verification", {
description:
error instanceof Error
? error.message
: "Failed to start verification",
});
} finally {
this.startingVerification = false;
}
}
async verifyCode() {
if (!this.verificationToken) {
this.errorMessage = "No verification session found";
return;
}
if (!this.verificationCode || this.verificationCode.length !== 6) {
this.errorMessage = "Please enter a valid 6-digit code";
return;
}
this.verifying = true;
this.errorMessage = null;
try {
const result = await verifySessionCodeSC({
verificationToken: this.verificationToken,
code: this.verificationCode,
});
if (result?.error || !result?.data?.success) {
this.errorMessage =
result?.error?.message || "Failed to verify code";
if (result?.error?.code === "BANNED") {
await authClient.signOut();
window.location.href = "/auth/login";
return;
}
if (
this.errorMessage &&
!this.errorMessage.includes("Invalid") &&
!this.errorMessage.includes("already been used")
) {
toast.error("Verification failed", {
description: this.errorMessage,
});
}
return;
}
const redirectUrl = page.url.searchParams.get("redirect") || "/";
window.location.href = redirectUrl;
} catch (error) {
this.errorMessage = "Failed to verify code";
toast.error("Verification failed", {
description:
error instanceof Error ? error.message : this.errorMessage,
});
} finally {
this.verifying = false;
}
}
async handleBackupCode() {
if (!this.verificationToken || !this.verificationCode) {
this.errorMessage = "Please enter a valid backup code";
return;
}
this.verifying = true;
this.errorMessage = null;
try {
const result = await verifySessionCodeSC({
verificationToken: this.verificationToken,
code: this.verificationCode,
});
if (result?.error || !result?.data?.success) {
this.errorMessage = result?.error?.message || "Invalid backup code";
return;
}
const redirectUrl = page.url.searchParams.get("redirect") || "/";
window.location.href = redirectUrl;
} catch (error) {
this.errorMessage =
error instanceof Error ? error.message : "Invalid backup code";
} finally {
this.verifying = false;
}
}
reset() {
this.verifying = false;
this.verificationCode = "";
this.verificationToken = null;
this.errorMessage = null;
this.startingVerification = false;
}
}
export const twoFactorVerifyVM = new TwoFactorVerifyViewModel();

View File

@@ -0,0 +1,234 @@
import {
disableTwoFactorSC,
generateBackupCodesSQ,
setupTwoFactorSC,
verifyAndEnableTwoFactorSC,
} from "$lib/domains/security/twofa.remote";
import { toast } from "svelte-sonner";
import QRCode from "qrcode";
class TwoFactorViewModel {
twoFactorEnabled = $state(false);
twoFactorSetupInProgress = $state(false);
showingBackupCodes = $state(false);
qrCodeUrl = $state<string | null>(null);
twoFactorSecret = $state<string | null>(null);
backupCodes = $state<string[]>([]);
twoFactorVerificationCode = $state("");
isLoading = $state(false);
errorMessage = $state<string | null>(null);
async startTwoFactorSetup() {
this.isLoading = true;
this.errorMessage = null;
try {
const result = await setupTwoFactorSC({});
if (result?.error || !result?.data?.totpURI) {
this.errorMessage =
result?.error?.message || "Could not enable 2FA";
toast.error(this.errorMessage, {
description:
result?.error?.description || "Please try again later",
});
return;
}
const qrCodeDataUrl = await QRCode.toDataURL(result.data.totpURI, {
width: 256,
margin: 2,
color: { dark: "#000000", light: "#FFFFFF" },
});
this.qrCodeUrl = qrCodeDataUrl;
this.twoFactorSetupInProgress = true;
this.twoFactorSecret = result.data.secret;
this.twoFactorVerificationCode = "";
toast("Setup enabled");
} catch (error) {
this.errorMessage = "Could not enable 2FA";
toast.error(this.errorMessage, {
description:
error instanceof Error ? error.message : "Please try again later",
});
} finally {
this.isLoading = false;
}
}
async completeTwoFactorSetup() {
if (!this.twoFactorVerificationCode) {
this.errorMessage =
"Please enter the verification code from your authenticator app.";
return;
}
this.isLoading = true;
this.errorMessage = null;
try {
const verifyResult = await verifyAndEnableTwoFactorSC({
code: this.twoFactorVerificationCode,
});
if (verifyResult?.error) {
this.errorMessage =
verifyResult.error.message || "Invalid verification code";
toast.error(this.errorMessage, {
description:
verifyResult.error.description || "Please try again",
});
return;
}
const backupCodesResult = await generateBackupCodesSQ();
if (backupCodesResult?.error || !Array.isArray(backupCodesResult?.data)) {
toast.error("2FA enabled, but failed to generate backup codes", {
description: "You can generate them later in settings",
});
} else {
this.backupCodes = backupCodesResult.data;
this.showingBackupCodes = true;
}
this.twoFactorEnabled = true;
this.twoFactorSetupInProgress = false;
this.twoFactorVerificationCode = "";
toast.success("Two-factor authentication enabled", {
description: "Your account is now more secure",
});
} catch (error) {
this.errorMessage = "Invalid verification code";
toast.error(this.errorMessage, {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
this.isLoading = false;
}
}
async disableTwoFactor() {
this.isLoading = true;
this.errorMessage = null;
try {
const result = await disableTwoFactorSC({ code: "" });
if (result?.error) {
this.errorMessage = result.error.message || "Failed to disable 2FA";
toast.error(this.errorMessage, {
description: result.error.description || "Please try again later",
});
return;
}
this.twoFactorEnabled = false;
this.backupCodes = [];
this.qrCodeUrl = null;
this.showingBackupCodes = false;
this.twoFactorSecret = null;
toast.success("Two-factor authentication disabled");
} catch (error) {
this.errorMessage = "Failed to disable 2FA";
toast.error(this.errorMessage, {
description:
error instanceof Error ? error.message : "Please try again later",
});
} finally {
this.isLoading = false;
}
}
async generateNewBackupCodes() {
this.isLoading = true;
this.errorMessage = null;
try {
const result = await generateBackupCodesSQ();
if (result?.error || !Array.isArray(result?.data)) {
this.errorMessage =
result?.error?.message || "Failed to generate new backup codes";
toast.error(this.errorMessage, {
description:
result?.error?.description || "Please try again later",
});
return;
}
this.backupCodes = result.data;
this.showingBackupCodes = true;
toast.success("New backup codes generated", {
description: "Your previous backup codes are now invalid",
});
} catch (error) {
this.errorMessage = "Failed to generate new backup codes";
toast.error(this.errorMessage, {
description:
error instanceof Error ? error.message : "Please try again later",
});
} finally {
this.isLoading = false;
}
}
copyAllBackupCodes() {
const codesText = this.backupCodes.join("\n");
navigator.clipboard.writeText(codesText);
toast.success("All backup codes copied to clipboard");
}
downloadBackupCodes() {
const codesText = this.backupCodes.join("\n");
const blob = new Blob(
[
`Two-Factor Authentication Backup Codes\n\nGenerated: ${new Date().toLocaleString()}\n\n${codesText}\n\nKeep these codes in a safe place. Each code can only be used once.`,
],
{
type: "text/plain",
},
);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `2fa-backup-codes-${new Date().toISOString().split("T")[0]}.txt`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Backup codes downloaded");
}
confirmBackupCodesSaved() {
this.showingBackupCodes = false;
toast.success("Great! Your backup codes are safely stored");
}
cancelSetup() {
this.twoFactorSetupInProgress = false;
this.twoFactorSecret = null;
this.qrCodeUrl = null;
this.twoFactorVerificationCode = "";
this.errorMessage = null;
this.showingBackupCodes = false;
}
copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
reset() {
this.twoFactorEnabled = false;
this.twoFactorSetupInProgress = false;
this.showingBackupCodes = false;
this.qrCodeUrl = null;
this.backupCodes = [];
this.twoFactorSecret = null;
this.twoFactorVerificationCode = "";
this.isLoading = false;
this.errorMessage = null;
}
}
export const twofactorVM = new TwoFactorViewModel();

View File

@@ -0,0 +1,71 @@
import { authClient } from "$lib/auth.client";
import { toast } from "svelte-sonner";
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import type { Err } from "@pkg/result";
class AuthViewModel {
loggingIn = $state(false);
async loginWithCredentials(data: FormData): Promise<boolean> {
const username = data.get("username")?.toString().trim();
const password = data.get("password")?.toString();
if (!username || username.length < 3) {
toast.error("Please enter a valid username");
return false;
}
if (!password || password.length < 6) {
toast.error("Please enter a valid password");
return false;
}
this.loggingIn = true;
const result = await ResultAsync.fromPromise(
authClient.signIn.username({ username, password }),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to login",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (response.error) {
return errAsync({
code: "API_ERROR",
message: response.error.message ?? "Invalid credentials",
description:
response.error.statusText ??
"Please check your username and password",
detail: response.error.statusText ?? "Unknown error",
});
}
return okAsync(response.data);
});
const success = result.match(
() => {
toast.success("Login successful", {
description: "Redirecting...",
});
setTimeout(() => {
window.location.href = "/";
}, 500);
return true;
},
(error) => {
toast.error(error.message ?? "Invalid credentials", {
description: error.description,
});
return false;
},
);
this.loggingIn = false;
return success;
}
}
export const authVM = new AuthViewModel();

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import { authVM } from "$lib/domains/security/auth.vm.svelte";
import { AtSign, KeyRound, Loader2 } from "@lucide/svelte";
let username = $state<string>("");
let password = $state<string>("");
const canSubmit = $derived(
username.trim().length >= 3 && password.length >= 6 && !authVM.loggingIn,
);
async function onSubmit(
e: SubmitEvent & { currentTarget: HTMLFormElement },
) {
e.preventDefault();
if (!canSubmit) return;
await authVM.loginWithCredentials(new FormData(e.currentTarget));
}
</script>
<form class="space-y-4" onsubmit={onSubmit}>
<div class="space-y-2">
<label
for="username"
class="text-foreground flex items-center gap-2 text-sm font-medium"
>
<Icon icon={AtSign} cls="h-4 w-4 text-muted-foreground" />
<span>Username</span>
</label>
<Input
id="username"
name="username"
type="text"
required
minlength={3}
maxlength={64}
placeholder="your-username"
bind:value={username}
class="h-12"
/>
</div>
<div class="space-y-2">
<label
for="password"
class="text-foreground flex items-center gap-2 text-sm font-medium"
>
<Icon icon={KeyRound} cls="h-4 w-4 text-muted-foreground" />
<span>Password</span>
</label>
<Input
id="password"
name="password"
type="password"
required
minlength={6}
placeholder="Your password"
bind:value={password}
class="h-12"
/>
</div>
<Button
type="submit"
class="from-primary to-primary/90 hover:from-primary/90 hover:to-primary h-12 w-full bg-gradient-to-r text-base font-medium shadow-lg transition-all duration-200 hover:shadow-xl"
disabled={!canSubmit}
>
{#if authVM.loggingIn}
<Icon icon={Loader2} cls="mr-2 h-5 w-5 animate-spin" />
Signing In...
{:else}
Sign In
{/if}
</Button>
</form>

View File

@@ -0,0 +1,324 @@
<script lang="ts">
import ButtonText from "$lib/components/atoms/button-text.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { Button, buttonVariants } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import * as InputOTP from "$lib/components/ui/input-otp";
import { twofactorVM } from "$lib/domains/security/2fa.vm.svelte";
import {
CheckCircle,
Copy,
Download,
Loader2,
ShieldAlert,
ShieldCheck,
} from "@lucide/svelte";
import { toast } from "svelte-sonner";
$inspect(twofactorVM.twoFactorSetupInProgress);
$inspect(twofactorVM.twoFactorEnabled);
</script>
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<Icon icon={ShieldCheck} cls="h-5 w-5 text-primary" />
<Card.Title>Two-Factor Authentication</Card.Title>
</div>
<Card.Description>
Add an extra layer of security to your account by enabling two-factor
authentication.
</Card.Description>
</Card.Header>
<Card.Content>
{#if twofactorVM.twoFactorSetupInProgress}
<div class="space-y-6">
<div class="bg-muted/50 rounded-lg border p-4">
<h4 class="mb-2 font-medium">Setup Instructions</h4>
<ol class="list-decimal space-y-2 pl-5 text-sm">
<li>
Install an authenticator app like Google Authenticator
or 2FAS on your mobile device.
</li>
<li>
Scan the QR code below or manually enter the secret
key into your app.
</li>
<li>
Enter the verification code displayed in your
authenticator app below.
</li>
</ol>
</div>
<div class="flex flex-col items-center gap-4">
{#if twofactorVM.qrCodeUrl && twofactorVM.qrCodeUrl.length > 0}
<div class="flex flex-col items-center gap-4">
{#if twofactorVM.qrCodeUrl}
<div class="rounded-lg border bg-white p-2">
<img
src={twofactorVM.qrCodeUrl}
alt="QR Code for Two-Factor Authentication"
class="h-48 w-48"
/>
</div>
<Button
variant="outline"
size="sm"
onclick={() => {
twofactorVM.copyToClipboard(
twofactorVM.twoFactorSecret || "",
);
toast.success(
"Secret copied to clipboard",
);
}}
>
<Icon icon={Copy} cls="mr-2 h-4 w-4" />
Copy secret for manual entry
</Button>
{:else}
<div class="flex justify-center py-6">
<Icon
icon={Loader2}
cls="h-8 w-8 animate-spin text-muted-foreground"
/>
</div>
{/if}
</div>
{:else}
<div class="flex justify-center py-6">
<Icon
icon={Loader2}
cls="h-8 w-8 animate-spin text-muted-foreground"
/>
</div>
{/if}
</div>
<div class="space-y-4">
<div class="flex justify-center">
<InputOTP.Root
maxlength={6}
bind:value={twofactorVM.twoFactorVerificationCode}
>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 3) as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(3, 6) as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
</div>
<p class="text-muted-foreground text-center text-sm">
Enter the 6-digit code from your authenticator app
</p>
</div>
<div class="flex w-32 gap-2">
<Button
onclick={() => twofactorVM.completeTwoFactorSetup()}
disabled={!twofactorVM.twoFactorVerificationCode ||
twofactorVM.twoFactorVerificationCode.length !== 6 ||
twofactorVM.isLoading}
>
{#if twofactorVM.isLoading}
<Icon
icon={Loader2}
cls="mr-2 h-4 w-4 animate-spin"
/>
{/if}
Verify and Enable
</Button>
<Button
variant="outline"
onclick={() => twofactorVM.cancelSetup()}
>
Cancel
</Button>
</div>
</div>
{:else if !twofactorVM.twoFactorEnabled && !twofactorVM.twoFactorSetupInProgress}
<div class="flex flex-col gap-4">
<div
class="flex flex-col items-center justify-between gap-4 sm:flex-row"
>
<div
class="flex flex-col gap-2 text-amber-700 sm:flex-row sm:items-center dark:text-amber-400"
>
<Icon icon={ShieldAlert} cls="h-4 w-4" />
<span class="text-sm">
Two-factor authentication is currently disabled.
</span>
</div>
<Button
variant="outline"
class="w-full sm:w-max"
onclick={() => twofactorVM.startTwoFactorSetup()}
disabled={twofactorVM.isLoading}
>
{#if twofactorVM.isLoading}
<Icon
icon={Loader2}
cls="mr-2 h-4 w-4 animate-spin"
/>
{/if}
Enable 2FA
</Button>
</div>
</div>
{:else if twofactorVM.twoFactorEnabled}
<div class="space-y-6">
{#if !twofactorVM.showingBackupCodes}
<div class="flex items-center justify-between">
<div
class="flex items-center gap-2 text-emerald-600 dark:text-emerald-400"
>
<Icon icon={ShieldCheck} cls="h-4 w-4" />
<span>Two-factor authentication is enabled.</span>
</div>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
onclick={() =>
twofactorVM.generateNewBackupCodes()}
disabled={twofactorVM.isLoading}
>
{#if twofactorVM.isLoading}
<Icon
icon={Loader2}
cls="mr-2 h-3 w-3 animate-spin"
/>
{:else}
Regenerate Recovery Codes
{/if}
</Button>
<AlertDialog.Root>
<AlertDialog.Trigger
class={buttonVariants({
variant: "destructive",
size: "sm",
})}
disabled={twofactorVM.isLoading}
>
<ButtonText
loading={twofactorVM.isLoading}
text="Disable 2FA"
loadingText="Disabling..."
/>
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title
>Disable Two-Factor Authentication?</AlertDialog.Title
>
<AlertDialog.Description>
This will remove the extra layer of
security from your account. You will
no longer need your authenticator app
to sign in, and all backup codes will
be invalidated. You can re-enable 2FA
at any time.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel
>Cancel</AlertDialog.Cancel
>
<AlertDialog.Action
onclick={() =>
twofactorVM.disableTwoFactor()}
>
Disable 2FA
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>
</div>
{/if}
{#if twofactorVM.showingBackupCodes && twofactorVM.backupCodes.length > 0}
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">Recovery Codes</h4>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
onclick={() =>
twofactorVM.copyAllBackupCodes()}
>
<Icon icon={Copy} cls="mr-2 h-3 w-3" />
Copy All
</Button>
<Button
variant="outline"
size="sm"
onclick={() =>
twofactorVM.downloadBackupCodes()}
>
<Icon icon={Download} cls="mr-2 h-3 w-3" />
Download
</Button>
</div>
</div>
<div class="bg-muted/50 rounded-lg border p-4">
<div class="grid grid-cols-2 gap-2">
{#each twofactorVM.backupCodes as code}
<div
class="bg-background flex items-center justify-between rounded border px-3 py-2 font-mono text-sm"
>
<span>{code}</span>
</div>
{/each}
</div>
<div class="mt-4 space-y-3">
<p class="text-muted-foreground text-xs">
<Icon
icon={ShieldCheck}
cls="mr-1 h-3 w-3 inline-block"
/>
Keep these codes in a safe place. Each code can
only be used once to access your account if you
lose your phone.
</p>
<div
class="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950/20"
>
<p
class="text-xs font-medium text-amber-800 dark:text-amber-200"
>
⚠️ Important: Save these codes before
continuing. You won't be able to see them
again.
</p>
</div>
</div>
</div>
<Button
onclick={() => twofactorVM.confirmBackupCodesSaved()}
class="w-full"
>
<Icon icon={CheckCircle} cls="mr-2 h-4 w-4" />
I've saved these codes securely
</Button>
</div>
{/if}
</div>
{/if}
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,205 @@
import {
disable2FASchema,
enable2FACodeSchema,
startVerificationSchema,
verifyCodeSchema,
} from "@pkg/logic/domains/2fa/data";
import {
getFlowExecCtxForRemoteFuncs,
unauthorized,
} from "$lib/core/server.utils";
import { getTwofaController } from "@pkg/logic/domains/2fa/controller";
import { command, getRequestEvent, query } from "$app/server";
import { auth } from "@pkg/logic/domains/auth/config.base";
import type { User } from "@pkg/logic/domains/user/data";
import * as v from "valibot";
const tc = getTwofaController();
function buildIpAddress(headers: Headers) {
return (
headers.get("x-forwarded-for") ?? headers.get("x-real-ip") ?? "unknown"
);
}
function buildUserAgent(headers: Headers) {
return headers.get("user-agent") ?? "unknown";
}
export const setupTwoFactorSC = command(v.object({}), async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
const currentUser = event.locals.user;
if (!fctx.userId || !currentUser) {
return unauthorized(fctx);
}
const res = await tc.setup2FA(fctx, currentUser as User);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const verifyAndEnableTwoFactorSC = command(
enable2FACodeSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
const currentUser = event.locals.user;
if (!fctx.userId || !currentUser) {
return unauthorized(fctx);
}
const res = await tc.verifyAndEnable2FA(
fctx,
currentUser as User,
payload.code,
event.request.headers,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const generateBackupCodesSQ = query(async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
const currentUser = event.locals.user;
if (!fctx.userId || !currentUser) {
return unauthorized(fctx);
}
const res = await tc.generateBackupCodes(fctx, currentUser as User);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const disableTwoFactorSC = command(disable2FASchema, async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
const currentUser = event.locals.user;
if (!fctx.userId || !currentUser) {
return unauthorized(fctx);
}
const res = await tc.disable(fctx, currentUser as User, payload.code);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const requiresVerificationSQ = query(
v.object({ sessionId: v.string() }),
async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
const currentUser = event.locals.user;
if (!fctx.userId || !currentUser) {
return unauthorized(fctx);
}
const res = await tc.requiresInitialVerification(
fctx,
currentUser as User,
input.sessionId,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const requiresSensitiveActionSQ = query(async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
const currentUser = event.locals.user;
if (!fctx.userId || !currentUser) {
return unauthorized(fctx);
}
const res = await tc.requiresSensitiveActionVerification(
fctx,
currentUser as User,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const startVerificationSessionSC = command(
startVerificationSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await tc.startVerification(fctx, {
userId: payload.userId,
sessionId: payload.sessionId,
ipAddress: buildIpAddress(event.request.headers),
userAgent: buildUserAgent(event.request.headers),
});
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const verifySessionCodeSC = command(
verifyCodeSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
let currentUser = event.locals.user;
if (!currentUser) {
const sess = await auth.api.getSession({
headers: event.request.headers,
});
currentUser = sess?.user as User | undefined;
}
const res = await tc.verifyCode(
fctx,
{
verificationSessToken: payload.verificationToken,
code: payload.code,
},
currentUser as User | undefined,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const cleanupExpiredSessionsSC = command(v.object({}), async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await tc.cleanupExpiredSessions(fctx);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});