initttt
This commit is contained in:
168
apps/main/src/lib/domains/account/account.remote.ts
Normal file
168
apps/main/src/lib/domains/account/account.remote.ts
Normal 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 };
|
||||
},
|
||||
);
|
||||
153
apps/main/src/lib/domains/account/account.vm.svelte.ts
Normal file
153
apps/main/src/lib/domains/account/account.vm.svelte.ts
Normal 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();
|
||||
182
apps/main/src/lib/domains/account/sessions/sessions-card.svelte
Normal file
182
apps/main/src/lib/domains/account/sessions/sessions-card.svelte
Normal 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>
|
||||
209
apps/main/src/lib/domains/account/sessions/sessions.vm.svelte.ts
Normal file
209
apps/main/src/lib/domains/account/sessions/sessions.vm.svelte.ts
Normal 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();
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
155
apps/main/src/lib/domains/notifications/notifications.remote.ts
Normal file
155
apps/main/src/lib/domains/notifications/notifications.remote.ts
Normal 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 };
|
||||
});
|
||||
160
apps/main/src/lib/domains/security/2fa-verify.vm.svelte.ts
Normal file
160
apps/main/src/lib/domains/security/2fa-verify.vm.svelte.ts
Normal 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();
|
||||
234
apps/main/src/lib/domains/security/2fa.vm.svelte.ts
Normal file
234
apps/main/src/lib/domains/security/2fa.vm.svelte.ts
Normal 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();
|
||||
71
apps/main/src/lib/domains/security/auth.vm.svelte.ts
Normal file
71
apps/main/src/lib/domains/security/auth.vm.svelte.ts
Normal 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();
|
||||
78
apps/main/src/lib/domains/security/email-login-form.svelte
Normal file
78
apps/main/src/lib/domains/security/email-login-form.svelte
Normal 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>
|
||||
324
apps/main/src/lib/domains/security/two-fa-card.svelte
Normal file
324
apps/main/src/lib/domains/security/two-fa-card.svelte
Normal 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>
|
||||
205
apps/main/src/lib/domains/security/twofa.remote.ts
Normal file
205
apps/main/src/lib/domains/security/twofa.remote.ts
Normal 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 };
|
||||
});
|
||||
Reference in New Issue
Block a user