hopeful
This commit is contained in:
@@ -3,51 +3,7 @@ import tailwindcss from "@tailwindcss/vite";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()]
|
||||||
build: {
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
manualChunks: (id) => {
|
|
||||||
// Bundle highlight.js and lowlight together
|
|
||||||
if (id.includes("highlight.js") || id.includes("lowlight")) {
|
|
||||||
return "highlight";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bundle Mermaid separately (large library, only used on some posts)
|
|
||||||
if (id.includes("mermaid")) {
|
|
||||||
return "mermaid";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bundle all Tiptap extensions together (only used in editor)
|
|
||||||
if (id.includes("@tiptap") || id.includes("solid-tiptap")) {
|
|
||||||
return "tiptap";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bundle motion libraries
|
|
||||||
if (id.includes("motion") || id.includes("@motionone")) {
|
|
||||||
return "motion";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split other large vendor libraries
|
|
||||||
if (id.includes("node_modules")) {
|
|
||||||
// Keep all solid-related packages together to avoid circular deps
|
|
||||||
if (
|
|
||||||
id.includes("@solidjs") ||
|
|
||||||
id.includes("solid-js") ||
|
|
||||||
id.includes("seroval")
|
|
||||||
) {
|
|
||||||
return "solid";
|
|
||||||
}
|
|
||||||
if (id.includes("@trpc")) {
|
|
||||||
return "trpc";
|
|
||||||
}
|
|
||||||
// Don't create a generic vendor chunk - let Vite handle it
|
|
||||||
// to avoid circular dependencies with solid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
preset: "vercel"
|
preset: "vercel"
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"redis": "^5.10.0",
|
"redis": "^5.10.0",
|
||||||
"solid-js": "^1.9.5",
|
"solid-js": "^1.9.5",
|
||||||
"solid-tiptap": "^0.8.0",
|
"solid-tiptap": "^0.8.0",
|
||||||
|
"ua-parser-js": "^2.0.7",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vinxi": "^0.5.7",
|
"vinxi": "^0.5.7",
|
||||||
"zod": "^4.2.1"
|
"zod": "^4.2.1"
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export const model: { [key: string]: string } = {
|
|||||||
ip_address TEXT,
|
ip_address TEXT,
|
||||||
user_agent TEXT,
|
user_agent TEXT,
|
||||||
revoked INTEGER DEFAULT 0,
|
revoked INTEGER DEFAULT 0,
|
||||||
|
device_name TEXT,
|
||||||
|
device_type TEXT,
|
||||||
|
browser TEXT,
|
||||||
|
os TEXT,
|
||||||
|
last_active_at TEXT DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE,
|
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (parent_session_id) REFERENCES Session(id) ON DELETE SET NULL
|
FOREIGN KEY (parent_session_id) REFERENCES Session(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
@@ -38,6 +43,28 @@ export const model: { [key: string]: string } = {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_session_token_family ON Session (token_family);
|
CREATE INDEX IF NOT EXISTS idx_session_token_family ON Session (token_family);
|
||||||
CREATE INDEX IF NOT EXISTS idx_session_refresh_token_hash ON Session (refresh_token_hash);
|
CREATE INDEX IF NOT EXISTS idx_session_refresh_token_hash ON Session (refresh_token_hash);
|
||||||
CREATE INDEX IF NOT EXISTS idx_session_revoked ON Session (revoked);
|
CREATE INDEX IF NOT EXISTS idx_session_revoked ON Session (revoked);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_last_active ON Session (last_active_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_user_active ON Session (user_id, revoked, last_active_at);
|
||||||
|
`,
|
||||||
|
UserProvider: `
|
||||||
|
CREATE TABLE UserProvider
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL CHECK(provider IN ('email', 'google', 'github', 'apple')),
|
||||||
|
provider_user_id TEXT,
|
||||||
|
email TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
image TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_user ON UserProvider (provider, provider_user_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_email ON UserProvider (provider, email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_provider_user_id ON UserProvider (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_provider_provider ON UserProvider (provider);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_provider_email ON UserProvider (email);
|
||||||
`,
|
`,
|
||||||
PasswordResetToken: `
|
PasswordResetToken: `
|
||||||
CREATE TABLE PasswordResetToken
|
CREATE TABLE PasswordResetToken
|
||||||
|
|||||||
@@ -27,6 +27,23 @@ export interface Session {
|
|||||||
ip_address?: string | null;
|
ip_address?: string | null;
|
||||||
user_agent?: string | null;
|
user_agent?: string | null;
|
||||||
revoked: number;
|
revoked: number;
|
||||||
|
device_name?: string | null;
|
||||||
|
device_type?: string | null;
|
||||||
|
browser?: string | null;
|
||||||
|
os?: string | null;
|
||||||
|
last_active_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProvider {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
provider: "email" | "google" | "github" | "apple"; // apple is for Life and Lineage mobile app only
|
||||||
|
provider_user_id?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
display_name?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordResetToken {
|
export interface PasswordResetToken {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, Show, createEffect } from "solid-js";
|
import { createSignal, Show, createEffect, For } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { useNavigate, redirect, query, createAsync } from "@solidjs/router";
|
import { useNavigate, redirect, query, createAsync } from "@solidjs/router";
|
||||||
import XCircle from "~/components/icons/XCircle";
|
import XCircle from "~/components/icons/XCircle";
|
||||||
@@ -858,6 +858,30 @@ export default function AccountPage() {
|
|||||||
|
|
||||||
<hr class="mt-8 mb-8" />
|
<hr class="mt-8 mb-8" />
|
||||||
|
|
||||||
|
{/* Linked Providers Section */}
|
||||||
|
<div class="mx-auto max-w-2xl py-8">
|
||||||
|
<div class="mb-6 text-center text-2xl font-semibold">
|
||||||
|
Linked Authentication Methods
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface0 border-surface1 rounded-lg border px-6 py-4 shadow-sm">
|
||||||
|
<LinkedProviders userId={userProfile().id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="mt-8 mb-8" />
|
||||||
|
|
||||||
|
{/* Active Sessions Section */}
|
||||||
|
<div class="mx-auto max-w-2xl py-8">
|
||||||
|
<div class="mb-6 text-center text-2xl font-semibold">
|
||||||
|
Active Sessions
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface0 border-surface1 rounded-lg border px-6 py-4 shadow-sm">
|
||||||
|
<ActiveSessions userId={userProfile().id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="mt-8 mb-8" />
|
||||||
|
|
||||||
{/* Sign Out Section */}
|
{/* Sign Out Section */}
|
||||||
<div class="mx-auto max-w-md py-4">
|
<div class="mx-auto max-w-md py-4">
|
||||||
<Button
|
<Button
|
||||||
@@ -950,3 +974,265 @@ export default function AccountPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LinkedProviders(props: { userId: string }) {
|
||||||
|
const [providers, setProviders] = createSignal<any[]>([]);
|
||||||
|
const [loading, setLoading] = createSignal(true);
|
||||||
|
const [unlinkLoading, setUnlinkLoading] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
const loadProviders = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/trpc/user.getProviders");
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok && result.result?.data) {
|
||||||
|
setProviders(result.result.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load providers:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
loadProviders();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUnlink = async (provider: string) => {
|
||||||
|
if (!confirm(`Are you sure you want to unlink your ${provider} account?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUnlinkLoading(provider);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/trpc/user.unlinkProvider", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ provider })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok && result.result?.data?.success) {
|
||||||
|
await loadProviders();
|
||||||
|
alert(`${provider} account unlinked successfully`);
|
||||||
|
} else {
|
||||||
|
alert(result.error?.message || "Failed to unlink provider");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to unlink provider:", err);
|
||||||
|
alert("Failed to unlink provider");
|
||||||
|
} finally {
|
||||||
|
setUnlinkLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProviderIcon = (provider: string) => {
|
||||||
|
switch (provider) {
|
||||||
|
case "google":
|
||||||
|
return <GoogleLogo height={20} width={20} />;
|
||||||
|
case "github":
|
||||||
|
return <GitHub height={20} width={20} fill="currentColor" />;
|
||||||
|
case "email":
|
||||||
|
return <EmailIcon height={20} width={20} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Show when={loading()}>
|
||||||
|
<div class="text-center text-sm">Loading providers...</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!loading() && providers().length === 0}>
|
||||||
|
<div class="text-center text-sm">No linked providers found</div>
|
||||||
|
</Show>
|
||||||
|
<For each={providers()}>
|
||||||
|
{(provider) => (
|
||||||
|
<div class="bg-surface1 flex items-center justify-between rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-blue">
|
||||||
|
{getProviderIcon(provider.provider)}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold capitalize">{provider.provider}</div>
|
||||||
|
<Show when={provider.email}>
|
||||||
|
<div class="text-subtext0 text-sm">{provider.email}</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={provider.lastUsedAt}>
|
||||||
|
<div class="text-subtext0 text-xs">
|
||||||
|
Last used: {formatDate(provider.lastUsedAt)}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={providers().length > 1}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnlink(provider.provider)}
|
||||||
|
disabled={unlinkLoading() === provider.provider}
|
||||||
|
class="text-red hover:text-red rounded px-3 py-1 text-sm transition-all hover:brightness-125 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{unlinkLoading() === provider.provider
|
||||||
|
? "Unlinking..."
|
||||||
|
: "Unlink"}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={providers().length === 1}>
|
||||||
|
<div class="text-subtext0 text-xs italic">Primary method</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActiveSessions(props: { userId: string }) {
|
||||||
|
const [sessions, setSessions] = createSignal<any[]>([]);
|
||||||
|
const [loading, setLoading] = createSignal(true);
|
||||||
|
const [revokeLoading, setRevokeLoading] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
const loadSessions = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/trpc/user.getSessions");
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok && result.result?.data) {
|
||||||
|
setSessions(result.result.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load sessions:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
loadSessions();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRevoke = async (sessionId: string, isCurrent: boolean) => {
|
||||||
|
if (isCurrent) {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
"This will sign you out of this device. Are you sure you want to continue?"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!confirm("Are you sure you want to revoke this session?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setRevokeLoading(sessionId);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/trpc/user.revokeSession", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ sessionId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok && result.result?.data?.success) {
|
||||||
|
if (isCurrent) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
} else {
|
||||||
|
await loadSessions();
|
||||||
|
alert("Session revoked successfully");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(result.error?.message || "Failed to revoke session");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to revoke session:", err);
|
||||||
|
alert("Failed to revoke session");
|
||||||
|
} finally {
|
||||||
|
setRevokeLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseUserAgent = (ua: string) => {
|
||||||
|
const browser =
|
||||||
|
ua.match(/(Chrome|Firefox|Safari|Edge)\/[\d.]+/)?.[0] ||
|
||||||
|
"Unknown browser";
|
||||||
|
const os = ua.match(/(Windows|Mac|Linux|Android|iOS)/)?.[0] || "Unknown OS";
|
||||||
|
return { browser, os };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Show when={loading()}>
|
||||||
|
<div class="text-center text-sm">Loading sessions...</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!loading() && sessions().length === 0}>
|
||||||
|
<div class="text-center text-sm">No active sessions found</div>
|
||||||
|
</Show>
|
||||||
|
<For each={sessions()}>
|
||||||
|
{(session) => {
|
||||||
|
const { browser, os } = parseUserAgent(session.userAgent || "");
|
||||||
|
return (
|
||||||
|
<div class="bg-surface1 rounded-lg p-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="font-semibold">{browser}</div>
|
||||||
|
<Show when={session.isCurrent}>
|
||||||
|
<span class="text-green bg-green/20 rounded px-2 py-0.5 text-xs font-semibold">
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="text-subtext0 mt-1 space-y-1 text-sm">
|
||||||
|
<div>{os}</div>
|
||||||
|
<Show when={session.clientIp}>
|
||||||
|
<div>IP: {session.clientIp}</div>
|
||||||
|
</Show>
|
||||||
|
<div>
|
||||||
|
Last active:{" "}
|
||||||
|
{formatDate(session.lastRotatedAt || session.createdAt)}
|
||||||
|
</div>
|
||||||
|
<Show when={session.expiresAt}>
|
||||||
|
<div class="text-xs">
|
||||||
|
Expires: {formatDate(session.expiresAt)}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleRevoke(session.sessionId, session.isCurrent)
|
||||||
|
}
|
||||||
|
disabled={revokeLoading() === session.sessionId}
|
||||||
|
class="text-red hover:text-red rounded px-3 py-1 text-sm transition-all hover:brightness-125 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{revokeLoading() === session.sessionId
|
||||||
|
? "Revoking..."
|
||||||
|
: "Revoke"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -217,7 +217,11 @@ export default function LoginPage() {
|
|||||||
errorMsg.includes("duplicate") ||
|
errorMsg.includes("duplicate") ||
|
||||||
errorMsg.includes("already exists")
|
errorMsg.includes("already exists")
|
||||||
) {
|
) {
|
||||||
|
if (errorMsg.includes("sign in and add a password")) {
|
||||||
|
setError("provider_exists");
|
||||||
|
} else {
|
||||||
setError("duplicate");
|
setError("duplicate");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
}
|
}
|
||||||
@@ -423,6 +427,16 @@ export default function LoginPage() {
|
|||||||
Email Already Exists!
|
Email Already Exists!
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={error() === "provider_exists"}>
|
||||||
|
<div class="mb-2 text-base font-semibold">
|
||||||
|
Account Already Exists
|
||||||
|
</div>
|
||||||
|
<div class="text-crust text-sm">
|
||||||
|
An account with this email already exists. Please sign in
|
||||||
|
using your provider (Google/GitHub) and add a password from
|
||||||
|
your account settings.
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
error().includes("Account locked") ||
|
error().includes("Account locked") ||
|
||||||
@@ -445,6 +459,7 @@ export default function LoginPage() {
|
|||||||
error() &&
|
error() &&
|
||||||
error() !== "passwordMismatch" &&
|
error() !== "passwordMismatch" &&
|
||||||
error() !== "duplicate" &&
|
error() !== "duplicate" &&
|
||||||
|
error() !== "provider_exists" &&
|
||||||
!error().includes("Account locked") &&
|
!error().includes("Account locked") &&
|
||||||
!error().includes("Account is locked") &&
|
!error().includes("Account is locked") &&
|
||||||
!error().includes("Too many attempts")
|
!error().includes("Too many attempts")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { blogRouter } from "./routers/blog";
|
|||||||
import { gitActivityRouter } from "./routers/git-activity";
|
import { gitActivityRouter } from "./routers/git-activity";
|
||||||
import { postHistoryRouter } from "./routers/post-history";
|
import { postHistoryRouter } from "./routers/post-history";
|
||||||
import { infillRouter } from "./routers/infill";
|
import { infillRouter } from "./routers/infill";
|
||||||
|
import { accountRouter } from "./routers/account";
|
||||||
import { createTRPCRouter, createTRPCContext } from "./utils";
|
import { createTRPCRouter, createTRPCContext } from "./utils";
|
||||||
import type { H3Event } from "h3";
|
import type { H3Event } from "h3";
|
||||||
|
|
||||||
@@ -23,7 +24,8 @@ export const appRouter = createTRPCRouter({
|
|||||||
blog: blogRouter,
|
blog: blogRouter,
|
||||||
gitActivity: gitActivityRouter,
|
gitActivity: gitActivityRouter,
|
||||||
postHistory: postHistoryRouter,
|
postHistory: postHistoryRouter,
|
||||||
infill: infillRouter
|
infill: infillRouter,
|
||||||
|
account: accountRouter
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
255
src/server/api/routers/account.ts
Normal file
255
src/server/api/routers/account.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { createTRPCRouter, protectedProcedure } from "../utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import {
|
||||||
|
getUserProviders,
|
||||||
|
unlinkProvider,
|
||||||
|
getProviderSummary
|
||||||
|
} from "~/server/provider-helpers";
|
||||||
|
import {
|
||||||
|
getUserActiveSessions,
|
||||||
|
revokeUserSession,
|
||||||
|
revokeOtherUserSessions,
|
||||||
|
getSessionCountByDevice
|
||||||
|
} from "~/server/session-management";
|
||||||
|
import { getAuthSession } from "~/server/session-helpers";
|
||||||
|
import { logAuditEvent } from "~/server/audit";
|
||||||
|
import { getAuditContext } from "~/server/security";
|
||||||
|
import type { H3Event } from "vinxi/http";
|
||||||
|
import type { Context } from "../utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract H3Event from Context
|
||||||
|
*/
|
||||||
|
function getH3Event(ctx: Context): H3Event {
|
||||||
|
if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) {
|
||||||
|
return ctx.event.nativeEvent as H3Event;
|
||||||
|
}
|
||||||
|
return ctx.event as unknown as H3Event;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accountRouter = createTRPCRouter({
|
||||||
|
/**
|
||||||
|
* Get all linked authentication providers for current user
|
||||||
|
*/
|
||||||
|
getLinkedProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
try {
|
||||||
|
const userId = ctx.userId!;
|
||||||
|
const summary = await getProviderSummary(userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
providers: summary.providers,
|
||||||
|
count: summary.count
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching linked providers:", error);
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to fetch linked providers"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink an authentication provider
|
||||||
|
*/
|
||||||
|
unlinkProvider: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
provider: z.enum(["email", "google", "github"])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const userId = ctx.userId!;
|
||||||
|
const { provider } = input;
|
||||||
|
|
||||||
|
await unlinkProvider(userId, provider);
|
||||||
|
|
||||||
|
// Log audit event
|
||||||
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
|
await logAuditEvent({
|
||||||
|
userId,
|
||||||
|
eventType: "auth.provider.unlinked",
|
||||||
|
eventData: { provider },
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `${provider} authentication unlinked successfully`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error unlinking provider:", error);
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to unlink provider"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active sessions for current user
|
||||||
|
*/
|
||||||
|
getActiveSessions: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
try {
|
||||||
|
const userId = ctx.userId!;
|
||||||
|
const sessions = await getUserActiveSessions(userId);
|
||||||
|
|
||||||
|
// Mark current session
|
||||||
|
const currentSession = await getAuthSession(getH3Event(ctx));
|
||||||
|
const currentSessionId = currentSession?.sessionId;
|
||||||
|
|
||||||
|
const sessionsWithCurrent = sessions.map((session) => ({
|
||||||
|
...session,
|
||||||
|
current: session.sessionId === currentSessionId
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
sessions: sessionsWithCurrent
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching active sessions:", error);
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to fetch active sessions"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session statistics by device type
|
||||||
|
*/
|
||||||
|
getSessionStats: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
try {
|
||||||
|
const userId = ctx.userId!;
|
||||||
|
const stats = await getSessionCountByDevice(userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
stats
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching session stats:", error);
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to fetch session stats"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a specific session
|
||||||
|
*/
|
||||||
|
revokeSession: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionId: z.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const userId = ctx.userId!;
|
||||||
|
const { sessionId } = input;
|
||||||
|
|
||||||
|
await revokeUserSession(userId, sessionId);
|
||||||
|
|
||||||
|
// Log audit event
|
||||||
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
|
await logAuditEvent({
|
||||||
|
userId,
|
||||||
|
eventType: "auth.session_revoked",
|
||||||
|
eventData: { sessionId, reason: "user_request" },
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Session revoked successfully"
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error revoking session:", error);
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to revoke session"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all other sessions (keep current session active)
|
||||||
|
*/
|
||||||
|
revokeOtherSessions: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
|
try {
|
||||||
|
const userId = ctx.userId!;
|
||||||
|
|
||||||
|
// Get current session
|
||||||
|
const currentSession = await getAuthSession(getH3Event(ctx));
|
||||||
|
if (!currentSession) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "No active session found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokedCount = await revokeOtherUserSessions(
|
||||||
|
userId,
|
||||||
|
currentSession.sessionId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log audit event
|
||||||
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
|
await logAuditEvent({
|
||||||
|
userId,
|
||||||
|
eventType: "auth.sessions_bulk_revoked",
|
||||||
|
eventData: {
|
||||||
|
revokedCount,
|
||||||
|
keptSession: currentSession.sessionId,
|
||||||
|
reason: "user_request"
|
||||||
|
},
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `${revokedCount} session(s) revoked successfully`,
|
||||||
|
revokedCount
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error revoking other sessions:", error);
|
||||||
|
|
||||||
|
if (error instanceof TRPCError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to revoke sessions"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -10,6 +10,12 @@ import {
|
|||||||
} from "~/server/utils";
|
} from "~/server/utils";
|
||||||
import { setCookie, getCookie } from "vinxi/http";
|
import { setCookie, getCookie } from "vinxi/http";
|
||||||
import type { User } from "~/db/types";
|
import type { User } from "~/db/types";
|
||||||
|
import {
|
||||||
|
linkProvider,
|
||||||
|
findUserByProvider,
|
||||||
|
findUserByEmail,
|
||||||
|
updateProviderLastUsed
|
||||||
|
} from "~/server/provider-helpers";
|
||||||
import {
|
import {
|
||||||
fetchWithTimeout,
|
fetchWithTimeout,
|
||||||
checkResponse,
|
checkResponse,
|
||||||
@@ -259,40 +265,53 @@ export const authRouter = createTRPCRouter({
|
|||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
console.log("[GitHub Callback] Checking if user exists...");
|
console.log("[GitHub Callback] Checking if user exists...");
|
||||||
const query = `SELECT * FROM User WHERE provider = ? AND display_name = ?`;
|
|
||||||
const params = ["github", login];
|
|
||||||
const res = await conn.execute({ sql: query, args: params });
|
|
||||||
|
|
||||||
let userId: string;
|
// Strategy 1: Check if this GitHub identity already linked
|
||||||
|
let userId = await findUserByProvider("github", login);
|
||||||
|
|
||||||
if (res.rows[0]) {
|
let isNewUser = false;
|
||||||
userId = (res.rows[0] as unknown as User).id;
|
let isLinkedAccount = false;
|
||||||
console.log("[GitHub Callback] Existing user found:", userId);
|
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
console.log(
|
||||||
|
"[GitHub Callback] Existing GitHub provider found:",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
// Update provider info
|
||||||
|
await updateProviderLastUsed(userId, "github");
|
||||||
|
} else {
|
||||||
|
// Strategy 2: Check if email matches existing user (account linking)
|
||||||
|
if (email) {
|
||||||
|
userId = await findUserByEmail(email);
|
||||||
|
if (userId) {
|
||||||
|
console.log(
|
||||||
|
"[GitHub Callback] Found existing user by email, linking GitHub account:",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
// Link GitHub to existing account
|
||||||
try {
|
try {
|
||||||
await conn.execute({
|
await linkProvider(userId, "github", {
|
||||||
sql: `UPDATE User SET email = ?, email_verified = ?, image = ? WHERE id = ?`,
|
providerUserId: login,
|
||||||
args: [email, emailVerified ? 1 : 0, icon, userId]
|
email: email,
|
||||||
|
displayName: login,
|
||||||
|
image: icon
|
||||||
});
|
});
|
||||||
console.log("[GitHub Callback] User data updated");
|
isLinkedAccount = true;
|
||||||
} catch (updateError: any) {
|
} catch (linkError: any) {
|
||||||
if (
|
|
||||||
updateError.code === "SQLITE_CONSTRAINT" &&
|
|
||||||
updateError.message?.includes("User.email")
|
|
||||||
) {
|
|
||||||
console.error(
|
console.error(
|
||||||
"[GitHub Callback] Email conflict during update:",
|
"[GitHub Callback] Failed to link provider:",
|
||||||
email
|
linkError.message
|
||||||
);
|
);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "CONFLICT",
|
code: "CONFLICT",
|
||||||
message:
|
message: linkError.message
|
||||||
"This email is already associated with another account. Please sign in with that account or use a different email address."
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw updateError;
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Create new user
|
||||||
|
if (!userId) {
|
||||||
userId = uuidV4();
|
userId = uuidV4();
|
||||||
console.log("[GitHub Callback] Creating new user:", userId);
|
console.log("[GitHub Callback] Creating new user:", userId);
|
||||||
|
|
||||||
@@ -308,6 +327,16 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await conn.execute({ sql: insertQuery, args: insertParams });
|
await conn.execute({ sql: insertQuery, args: insertParams });
|
||||||
|
|
||||||
|
// Also create UserProvider entry for new user
|
||||||
|
await linkProvider(userId, "github", {
|
||||||
|
providerUserId: login,
|
||||||
|
email: email,
|
||||||
|
displayName: login,
|
||||||
|
image: icon
|
||||||
|
});
|
||||||
|
|
||||||
|
isNewUser = true;
|
||||||
console.log("[GitHub Callback] New user created");
|
console.log("[GitHub Callback] New user created");
|
||||||
} catch (insertError: any) {
|
} catch (insertError: any) {
|
||||||
if (
|
if (
|
||||||
@@ -327,6 +356,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
throw insertError;
|
throw insertError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isAdmin = userId === env.ADMIN_ID;
|
const isAdmin = userId === env.ADMIN_ID;
|
||||||
|
|
||||||
@@ -352,7 +382,11 @@ export const authRouter = createTRPCRouter({
|
|||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId,
|
userId,
|
||||||
eventType: "auth.login.success",
|
eventType: "auth.login.success",
|
||||||
eventData: { method: "github", isNewUser: !res.rows[0] },
|
eventData: {
|
||||||
|
method: "github",
|
||||||
|
isNewUser,
|
||||||
|
isLinkedAccount
|
||||||
|
},
|
||||||
ipAddress: clientIP,
|
ipAddress: clientIP,
|
||||||
userAgent,
|
userAgent,
|
||||||
success: true
|
success: true
|
||||||
@@ -485,22 +519,51 @@ export const authRouter = createTRPCRouter({
|
|||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
console.log("[Google Callback] Checking if user exists...");
|
console.log("[Google Callback] Checking if user exists...");
|
||||||
const query = `SELECT * FROM User WHERE provider = ? AND email = ?`;
|
|
||||||
const params = ["google", email];
|
|
||||||
const res = await conn.execute({ sql: query, args: params });
|
|
||||||
|
|
||||||
let userId: string;
|
// Strategy 1: Check if this Google identity already linked
|
||||||
|
let userId = await findUserByProvider("google", email);
|
||||||
|
|
||||||
if (res.rows[0]) {
|
let isNewUser = false;
|
||||||
userId = (res.rows[0] as unknown as User).id;
|
let isLinkedAccount = false;
|
||||||
console.log("[Google Callback] Existing user found:", userId);
|
|
||||||
|
|
||||||
await conn.execute({
|
if (userId) {
|
||||||
sql: `UPDATE User SET email = ?, email_verified = ?, display_name = ?, image = ? WHERE id = ?`,
|
console.log(
|
||||||
args: [email, email_verified ? 1 : 0, name, image, userId]
|
"[Google Callback] Existing Google provider found:",
|
||||||
});
|
userId
|
||||||
console.log("[Google Callback] User data updated");
|
);
|
||||||
|
// Update provider info
|
||||||
|
await updateProviderLastUsed(userId, "google");
|
||||||
} else {
|
} else {
|
||||||
|
// Strategy 2: Check if email matches existing user (account linking)
|
||||||
|
userId = await findUserByEmail(email);
|
||||||
|
if (userId) {
|
||||||
|
console.log(
|
||||||
|
"[Google Callback] Found existing user by email, linking Google account:",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
// Link Google to existing account
|
||||||
|
try {
|
||||||
|
await linkProvider(userId, "google", {
|
||||||
|
providerUserId: email,
|
||||||
|
email: email,
|
||||||
|
displayName: name,
|
||||||
|
image: image
|
||||||
|
});
|
||||||
|
isLinkedAccount = true;
|
||||||
|
} catch (linkError: any) {
|
||||||
|
console.error(
|
||||||
|
"[Google Callback] Failed to link provider:",
|
||||||
|
linkError.message
|
||||||
|
);
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: linkError.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Create new user
|
||||||
|
if (!userId) {
|
||||||
userId = uuidV4();
|
userId = uuidV4();
|
||||||
console.log("[Google Callback] Creating new user:", userId);
|
console.log("[Google Callback] Creating new user:", userId);
|
||||||
|
|
||||||
@@ -519,6 +582,16 @@ export const authRouter = createTRPCRouter({
|
|||||||
sql: insertQuery,
|
sql: insertQuery,
|
||||||
args: insertParams
|
args: insertParams
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Also create UserProvider entry for new user
|
||||||
|
await linkProvider(userId, "google", {
|
||||||
|
providerUserId: email,
|
||||||
|
email: email,
|
||||||
|
displayName: name,
|
||||||
|
image: image
|
||||||
|
});
|
||||||
|
|
||||||
|
isNewUser = true;
|
||||||
console.log("[Google Callback] New user created");
|
console.log("[Google Callback] New user created");
|
||||||
} catch (insertError: any) {
|
} catch (insertError: any) {
|
||||||
if (
|
if (
|
||||||
@@ -538,6 +611,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
throw insertError;
|
throw insertError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isAdmin = userId === env.ADMIN_ID;
|
const isAdmin = userId === env.ADMIN_ID;
|
||||||
|
|
||||||
@@ -563,7 +637,11 @@ export const authRouter = createTRPCRouter({
|
|||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId,
|
userId,
|
||||||
eventType: "auth.login.success",
|
eventType: "auth.login.success",
|
||||||
eventData: { method: "google", isNewUser: !res.rows[0] },
|
eventData: {
|
||||||
|
method: "google",
|
||||||
|
isNewUser,
|
||||||
|
isLinkedAccount
|
||||||
|
},
|
||||||
ipAddress: clientIP,
|
ipAddress: clientIP,
|
||||||
userAgent,
|
userAgent,
|
||||||
success: true
|
success: true
|
||||||
@@ -989,6 +1067,36 @@ export const authRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if email already exists (User table or UserProvider table)
|
||||||
|
const existingUserId = await findUserByEmail(email);
|
||||||
|
if (existingUserId) {
|
||||||
|
// User exists - check if they have a password
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
const userCheck = await conn.execute({
|
||||||
|
sql: "SELECT password_hash, provider FROM User WHERE id = ?",
|
||||||
|
args: [existingUserId]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userCheck.rows.length > 0) {
|
||||||
|
const existingUser = userCheck.rows[0] as any;
|
||||||
|
|
||||||
|
// If user has a password, it's a duplicate registration attempt
|
||||||
|
if (existingUser.password_hash) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "duplicate"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user doesn't have a password (provider-only), redirect to login
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message:
|
||||||
|
"An account with this email already exists. Please sign in and add a password from your account settings."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
const userId = uuidV4();
|
const userId = uuidV4();
|
||||||
@@ -999,6 +1107,12 @@ export const authRouter = createTRPCRouter({
|
|||||||
args: [userId, email, passwordHash, "email"]
|
args: [userId, email, passwordHash, "email"]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create UserProvider entry for email auth
|
||||||
|
await linkProvider(userId, "email", {
|
||||||
|
providerUserId: email,
|
||||||
|
email: email
|
||||||
|
});
|
||||||
|
|
||||||
// Create session with client info
|
// Create session with client info
|
||||||
const clientIP = getClientIP(getH3Event(ctx));
|
const clientIP = getClientIP(getH3Event(ctx));
|
||||||
const userAgent = getUserAgent(getH3Event(ctx));
|
const userAgent = getUserAgent(getH3Event(ctx));
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
|
|||||||
import { setCookie } from "vinxi/http";
|
import { setCookie } from "vinxi/http";
|
||||||
import type { User } from "~/db/types";
|
import type { User } from "~/db/types";
|
||||||
import { toUserProfile } from "~/types/user";
|
import { toUserProfile } from "~/types/user";
|
||||||
import {
|
import { getUserProviders, unlinkProvider } from "~/server/provider-helpers";
|
||||||
updateEmailSchema,
|
import { z } from "zod";
|
||||||
updateDisplayNameSchema,
|
import { getAuthSession } from "~/server/session-helpers";
|
||||||
updateProfileImageSchema,
|
import { logAuditEvent } from "~/server/audit";
|
||||||
changePasswordSchema,
|
import { getClientIP, getUserAgent } from "~/server/security";
|
||||||
setPasswordSchema,
|
|
||||||
deleteAccountSchema
|
|
||||||
} from "../schemas/user";
|
|
||||||
|
|
||||||
export const userRouter = createTRPCRouter({
|
export const userRouter = createTRPCRouter({
|
||||||
getProfile: publicProcedure.query(async ({ ctx }) => {
|
getProfile: publicProcedure.query(async ({ ctx }) => {
|
||||||
@@ -242,6 +239,55 @@ export const userRouter = createTRPCRouter({
|
|||||||
args: [passwordHash, userId]
|
args: [passwordHash, userId]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send email notification about password being set
|
||||||
|
if (user.email) {
|
||||||
|
try {
|
||||||
|
const { generatePasswordSetEmail } =
|
||||||
|
await import("~/server/email-templates");
|
||||||
|
const { formatDeviceDescription } =
|
||||||
|
await import("~/server/device-utils");
|
||||||
|
const { default: sendEmail } = await import("~/server/email");
|
||||||
|
|
||||||
|
const h3Event = ctx.event.nativeEvent
|
||||||
|
? ctx.event.nativeEvent
|
||||||
|
: (ctx.event as any);
|
||||||
|
const clientIP = getClientIP(h3Event);
|
||||||
|
const userAgent = getUserAgent(h3Event);
|
||||||
|
|
||||||
|
const deviceInfo = formatDeviceDescription({
|
||||||
|
userAgent
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerName =
|
||||||
|
user.provider === "google"
|
||||||
|
? "Google"
|
||||||
|
: user.provider === "github"
|
||||||
|
? "GitHub"
|
||||||
|
: "provider";
|
||||||
|
|
||||||
|
const htmlContent = generatePasswordSetEmail({
|
||||||
|
providerName,
|
||||||
|
setTime: new Date().toLocaleString(),
|
||||||
|
deviceInfo,
|
||||||
|
ipAddress: clientIP
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendEmail(
|
||||||
|
user.email,
|
||||||
|
"Password Added to Your Account",
|
||||||
|
htmlContent
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[setPassword] Confirmation email sent to ${user.email}`);
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error(
|
||||||
|
"[setPassword] Failed to send confirmation email:",
|
||||||
|
emailError
|
||||||
|
);
|
||||||
|
// Don't fail the operation if email fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, message: "success" };
|
return { success: true, message: "success" };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -303,5 +349,152 @@ export const userRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, message: "deleted" };
|
return { success: true, message: "deleted" };
|
||||||
|
}),
|
||||||
|
|
||||||
|
getProviders: publicProcedure.query(async ({ ctx }) => {
|
||||||
|
const userId = ctx.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Not authenticated"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = await getUserProviders(userId);
|
||||||
|
|
||||||
|
return providers.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
provider: p.provider,
|
||||||
|
email: p.email || undefined,
|
||||||
|
displayName: p.display_name || undefined,
|
||||||
|
lastUsedAt: p.last_used_at,
|
||||||
|
createdAt: p.created_at
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
|
||||||
|
unlinkProvider: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
provider: z.enum(["email", "google", "github"])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const userId = ctx.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Not authenticated"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await unlinkProvider(userId, input.provider);
|
||||||
|
|
||||||
|
return { success: true, message: "Provider unlinked" };
|
||||||
|
}),
|
||||||
|
|
||||||
|
getSessions: publicProcedure.query(async ({ ctx }) => {
|
||||||
|
const userId = ctx.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Not authenticated"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
const res = await conn.execute({
|
||||||
|
sql: `SELECT session_id, token_family, created_at, expires_at, last_rotated_at,
|
||||||
|
rotation_count, client_ip, user_agent
|
||||||
|
FROM Session
|
||||||
|
WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
|
||||||
|
ORDER BY last_rotated_at DESC`,
|
||||||
|
args: [userId]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current session to mark it
|
||||||
|
const currentSession = await getAuthSession(ctx.event as any);
|
||||||
|
|
||||||
|
return res.rows.map((row: any) => ({
|
||||||
|
sessionId: row.session_id,
|
||||||
|
tokenFamily: row.token_family,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
lastRotatedAt: row.last_rotated_at,
|
||||||
|
rotationCount: row.rotation_count,
|
||||||
|
clientIp: row.client_ip,
|
||||||
|
userAgent: row.user_agent,
|
||||||
|
isCurrent: currentSession?.sessionId === row.session_id
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
|
||||||
|
revokeSession: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionId: z.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const userId = ctx.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Not authenticated"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
// Verify session belongs to this user
|
||||||
|
const sessionCheck = await conn.execute({
|
||||||
|
sql: "SELECT user_id, token_family FROM Session WHERE session_id = ?",
|
||||||
|
args: [input.sessionId]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionCheck.rows.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Session not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sessionCheck.rows[0] as any;
|
||||||
|
if (session.user_id !== userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Cannot revoke another user's session"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke the entire token family (all sessions on this device)
|
||||||
|
await conn.execute({
|
||||||
|
sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?",
|
||||||
|
args: [session.token_family]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log audit event
|
||||||
|
const h3Event = ctx.event.nativeEvent
|
||||||
|
? ctx.event.nativeEvent
|
||||||
|
: (ctx.event as any);
|
||||||
|
const clientIP = getClientIP(h3Event);
|
||||||
|
const userAgent = getUserAgent(h3Event);
|
||||||
|
|
||||||
|
await logAuditEvent({
|
||||||
|
userId,
|
||||||
|
eventType: "auth.session_revoked",
|
||||||
|
eventData: {
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
tokenFamily: session.token_family,
|
||||||
|
reason: "user_revoked"
|
||||||
|
},
|
||||||
|
ipAddress: clientIP,
|
||||||
|
userAgent,
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, message: "Session revoked" };
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
102
src/server/device-utils.ts
Normal file
102
src/server/device-utils.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import type { H3Event } from "vinxi/http";
|
||||||
|
import UAParser from "ua-parser-js";
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
deviceName?: string;
|
||||||
|
deviceType?: "desktop" | "mobile" | "tablet";
|
||||||
|
browser?: string;
|
||||||
|
os?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse user agent string to extract device information
|
||||||
|
* @param userAgent - User agent string from request headers
|
||||||
|
* @returns Parsed device information
|
||||||
|
*/
|
||||||
|
export function parseDeviceInfo(userAgent: string): DeviceInfo {
|
||||||
|
const parser = new UAParser(userAgent);
|
||||||
|
const result = parser.getResult();
|
||||||
|
|
||||||
|
// Determine device type
|
||||||
|
let deviceType: "desktop" | "mobile" | "tablet" = "desktop";
|
||||||
|
if (result.device.type === "mobile") {
|
||||||
|
deviceType = "mobile";
|
||||||
|
} else if (result.device.type === "tablet") {
|
||||||
|
deviceType = "tablet";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build device name (e.g., "iPhone 14", "Windows PC", "iPad Pro")
|
||||||
|
let deviceName: string | undefined;
|
||||||
|
if (result.device.vendor && result.device.model) {
|
||||||
|
deviceName = `${result.device.vendor} ${result.device.model}`;
|
||||||
|
} else if (result.os.name) {
|
||||||
|
deviceName = `${result.os.name} ${deviceType === "desktop" ? "Computer" : deviceType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser info (e.g., "Chrome 120")
|
||||||
|
const browser =
|
||||||
|
result.browser.name && result.browser.version
|
||||||
|
? `${result.browser.name} ${result.browser.version.split(".")[0]}`
|
||||||
|
: result.browser.name;
|
||||||
|
|
||||||
|
// OS info (e.g., "macOS 14.1", "Windows 11", "iOS 17")
|
||||||
|
const os =
|
||||||
|
result.os.name && result.os.version
|
||||||
|
? `${result.os.name} ${result.os.version}`
|
||||||
|
: result.os.name;
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceName,
|
||||||
|
deviceType,
|
||||||
|
browser,
|
||||||
|
os
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract device information from H3Event
|
||||||
|
* @param event - H3Event
|
||||||
|
* @returns Device information
|
||||||
|
*/
|
||||||
|
export function getDeviceInfo(event: H3Event): DeviceInfo {
|
||||||
|
const userAgent = event.node.req.headers["user-agent"] || "";
|
||||||
|
return parseDeviceInfo(userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a human-readable device description
|
||||||
|
* @param deviceInfo - Device information
|
||||||
|
* @returns Formatted device string (e.g., "Chrome on macOS", "iPhone")
|
||||||
|
*/
|
||||||
|
export function formatDeviceDescription(deviceInfo: DeviceInfo): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (deviceInfo.deviceName) {
|
||||||
|
parts.push(deviceInfo.deviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceInfo.browser) {
|
||||||
|
parts.push(deviceInfo.browser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceInfo.os && !deviceInfo.deviceName?.includes(deviceInfo.os)) {
|
||||||
|
parts.push(`on ${deviceInfo.os}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(" • ") : "Unknown Device";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a short device fingerprint for comparison
|
||||||
|
* Not cryptographic, just for grouping similar sessions
|
||||||
|
* @param deviceInfo - Device information
|
||||||
|
* @returns Short fingerprint string
|
||||||
|
*/
|
||||||
|
export function createDeviceFingerprint(deviceInfo: DeviceInfo): string {
|
||||||
|
const parts = [
|
||||||
|
deviceInfo.deviceType || "unknown",
|
||||||
|
deviceInfo.os?.split(" ")[0] || "unknown",
|
||||||
|
deviceInfo.browser?.split(" ")[0] || "unknown"
|
||||||
|
];
|
||||||
|
return parts.join("-").toLowerCase();
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import { AUTH_CONFIG } from "~/config";
|
|||||||
import loginLinkTemplate from "./login-link.html?raw";
|
import loginLinkTemplate from "./login-link.html?raw";
|
||||||
import passwordResetTemplate from "./password-reset.html?raw";
|
import passwordResetTemplate from "./password-reset.html?raw";
|
||||||
import emailVerificationTemplate from "./email-verification.html?raw";
|
import emailVerificationTemplate from "./email-verification.html?raw";
|
||||||
|
import providerLinkedTemplate from "./provider-linked.html?raw";
|
||||||
|
import newDeviceLoginTemplate from "./new-device-login.html?raw";
|
||||||
|
import passwordSetTemplate from "./password-set.html?raw";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert expiry string to human-readable format
|
* Convert expiry string to human-readable format
|
||||||
@@ -94,3 +97,68 @@ export function generateEmailVerificationEmail(
|
|||||||
EXPIRY_TIME: expiryTime
|
EXPIRY_TIME: expiryTime
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProviderLinkedEmailParams {
|
||||||
|
providerName: string;
|
||||||
|
providerEmail?: string;
|
||||||
|
linkTime: string;
|
||||||
|
deviceInfo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate provider linked notification email HTML
|
||||||
|
*/
|
||||||
|
export function generateProviderLinkedEmail(
|
||||||
|
params: ProviderLinkedEmailParams
|
||||||
|
): string {
|
||||||
|
return processTemplate(providerLinkedTemplate, {
|
||||||
|
PROVIDER_NAME: params.providerName,
|
||||||
|
PROVIDER_EMAIL: params.providerEmail || "N/A",
|
||||||
|
LINK_TIME: params.linkTime,
|
||||||
|
DEVICE_INFO: params.deviceInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewDeviceLoginEmailParams {
|
||||||
|
deviceInfo: string;
|
||||||
|
loginTime: string;
|
||||||
|
ipAddress: string;
|
||||||
|
loginMethod: string;
|
||||||
|
accountUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate new device login notification email HTML
|
||||||
|
*/
|
||||||
|
export function generateNewDeviceLoginEmail(
|
||||||
|
params: NewDeviceLoginEmailParams
|
||||||
|
): string {
|
||||||
|
return processTemplate(newDeviceLoginTemplate, {
|
||||||
|
DEVICE_INFO: params.deviceInfo,
|
||||||
|
LOGIN_TIME: params.loginTime,
|
||||||
|
IP_ADDRESS: params.ipAddress,
|
||||||
|
LOGIN_METHOD: params.loginMethod,
|
||||||
|
ACCOUNT_URL: params.accountUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordSetEmailParams {
|
||||||
|
providerName: string;
|
||||||
|
setTime: string;
|
||||||
|
deviceInfo: string;
|
||||||
|
ipAddress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate password set notification email HTML
|
||||||
|
*/
|
||||||
|
export function generatePasswordSetEmail(
|
||||||
|
params: PasswordSetEmailParams
|
||||||
|
): string {
|
||||||
|
return processTemplate(passwordSetTemplate, {
|
||||||
|
PROVIDER_NAME: params.providerName,
|
||||||
|
SET_TIME: params.setTime,
|
||||||
|
DEVICE_INFO: params.deviceInfo,
|
||||||
|
IP_ADDRESS: params.ipAddress
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
131
src/server/email-templates/new-device-login.html
Normal file
131
src/server/email-templates/new-device-login.html
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>New Device Login</title>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
style="
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px">
|
||||||
|
New Device Login Detected
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 30px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p style="font-size: 16px; margin-top: 0">Hello,</p>
|
||||||
|
|
||||||
|
<p style="font-size: 16px">
|
||||||
|
We detected a new login to your account from a device we haven't seen
|
||||||
|
before:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p style="margin: 0; font-size: 16px">
|
||||||
|
<strong>Device:</strong> {{DEVICE_INFO}}
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||||
|
<strong>Time:</strong> {{LOGIN_TIME}}
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||||
|
<strong>Location:</strong> {{IP_ADDRESS}}
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||||
|
<strong>Method:</strong> {{LOGIN_METHOD}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 16px">
|
||||||
|
If this was you, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p style="margin: 0; font-size: 14px; color: #856404">
|
||||||
|
<strong>⚠️ Wasn't you?</strong><br />
|
||||||
|
If you didn't log in from this device, your account may be
|
||||||
|
compromised. Please sign in immediately and:
|
||||||
|
</p>
|
||||||
|
<ul
|
||||||
|
style="
|
||||||
|
margin: 10px 0 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #856404;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<li>Revoke all active sessions</li>
|
||||||
|
<li>Change your password</li>
|
||||||
|
<li>Review linked authentication providers</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0">
|
||||||
|
<a
|
||||||
|
href="{{ACCOUNT_URL}}"
|
||||||
|
style="
|
||||||
|
display: inline-block;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 30px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Review Account Security
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 14px; color: #666; margin-bottom: 0">Best regards</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="text-align: center; padding: 20px; font-size: 12px; color: #999"
|
||||||
|
>
|
||||||
|
<p style="margin: 0">
|
||||||
|
This is an automated security notification from freno.me
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
103
src/server/email-templates/password-set.html
Normal file
103
src/server/email-templates/password-set.html
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Password Added to Account</title>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
style="
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px">
|
||||||
|
Password Added to Your Account
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 30px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p style="font-size: 16px; margin-top: 0">Hello,</p>
|
||||||
|
|
||||||
|
<p style="font-size: 16px">
|
||||||
|
A password has been successfully added to your account. You can now sign
|
||||||
|
in using your email and password in addition to your existing
|
||||||
|
authentication methods.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p style="margin: 0; font-size: 16px">
|
||||||
|
<strong>Time:</strong> {{SET_TIME}}
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||||
|
<strong>Device:</strong> {{DEVICE_INFO}}
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||||
|
<strong>IP Address:</strong> {{IP_ADDRESS}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 16px">
|
||||||
|
This provides you with an additional way to access your account and
|
||||||
|
ensures you can still sign in even if you lose access to your
|
||||||
|
{{PROVIDER_NAME}} account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p style="margin: 0; font-size: 14px; color: #856404">
|
||||||
|
<strong>⚠️ Didn't set this password?</strong><br />
|
||||||
|
If you didn't perform this action, your account security may be at
|
||||||
|
risk. Please sign in immediately, change your password, and review
|
||||||
|
your account settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 14px; color: #666; margin-bottom: 0">Best regards</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="text-align: center; padding: 20px; font-size: 12px; color: #999"
|
||||||
|
>
|
||||||
|
<p style="margin: 0">
|
||||||
|
This is an automated security notification from freno.me
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
102
src/server/email-templates/provider-linked.html
Normal file
102
src/server/email-templates/provider-linked.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>New Provider Linked</title>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
style="
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px">
|
||||||
|
New Login Method Linked
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 30px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p style="font-size: 16px; margin-top: 0">Hello,</p>
|
||||||
|
|
||||||
|
<p style="font-size: 16px">
|
||||||
|
A new authentication provider has been linked to your account:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p style="margin: 0; font-size: 16px">
|
||||||
|
<strong>Provider:</strong> {{PROVIDER_NAME}}
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||||
|
<strong>Email:</strong> {{PROVIDER_EMAIL}}
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||||
|
<strong>Time:</strong> {{LINK_TIME}}
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||||
|
<strong>Device:</strong> {{DEVICE_INFO}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 16px">
|
||||||
|
You can now sign in to your account using {{PROVIDER_NAME}}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p style="margin: 0; font-size: 14px; color: #856404">
|
||||||
|
<strong>⚠️ Didn't link this provider?</strong><br />
|
||||||
|
If you didn't perform this action, your account security may be at
|
||||||
|
risk. Please sign in and remove this provider immediately, then change
|
||||||
|
your password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 14px; color: #666; margin-bottom: 0">Best regards</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="text-align: center; padding: 20px; font-size: 12px; color: #999"
|
||||||
|
>
|
||||||
|
<p style="margin: 0">
|
||||||
|
This is an automated security notification from freno.me
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,9 +1,76 @@
|
|||||||
import { SignJWT } from "jose";
|
import { SignJWT } from "jose";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import { AUTH_CONFIG } from "~/config";
|
import { AUTH_CONFIG, NETWORK_CONFIG } from "~/config";
|
||||||
|
import {
|
||||||
|
fetchWithTimeout,
|
||||||
|
checkResponse,
|
||||||
|
fetchWithRetry
|
||||||
|
} from "~/server/fetch-utils";
|
||||||
|
|
||||||
export const LINEAGE_JWT_EXPIRY = AUTH_CONFIG.LINEAGE_JWT_EXPIRY;
|
export const LINEAGE_JWT_EXPIRY = AUTH_CONFIG.LINEAGE_JWT_EXPIRY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic email sending function
|
||||||
|
* @param to - Recipient email address
|
||||||
|
* @param subject - Email subject
|
||||||
|
* @param htmlContent - HTML content of the email
|
||||||
|
* @returns Success status
|
||||||
|
*/
|
||||||
|
export default async function sendEmail(
|
||||||
|
to: string,
|
||||||
|
subject: string,
|
||||||
|
htmlContent: string
|
||||||
|
): Promise<{ success: boolean; messageId?: string; message?: string }> {
|
||||||
|
const apiKey = env.SENDINBLUE_KEY;
|
||||||
|
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
|
||||||
|
|
||||||
|
const emailPayload = {
|
||||||
|
sender: {
|
||||||
|
name: "freno.me",
|
||||||
|
email: "no_reply@freno.me"
|
||||||
|
},
|
||||||
|
to: [{ email: to }],
|
||||||
|
htmlContent,
|
||||||
|
subject
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithRetry(
|
||||||
|
async () => {
|
||||||
|
const res = await fetchWithTimeout(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"api-key": apiKey,
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(emailPayload),
|
||||||
|
timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS
|
||||||
|
});
|
||||||
|
|
||||||
|
await checkResponse(res);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxRetries: NETWORK_CONFIG.MAX_RETRIES,
|
||||||
|
retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const json = (await response.json()) as { messageId?: string };
|
||||||
|
if (json.messageId) {
|
||||||
|
return { success: true, messageId: json.messageId };
|
||||||
|
}
|
||||||
|
return { success: false, message: "No messageId in response" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Email sending error:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : "Email service error"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendEmailVerification(userEmail: string): Promise<{
|
export async function sendEmailVerification(userEmail: string): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
|
|||||||
244
src/server/migrate-multi-auth.ts
Normal file
244
src/server/migrate-multi-auth.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { ConnectionFactory } from "./database";
|
||||||
|
import { v4 as uuidV4 } from "uuid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration script to add multi-provider and enhanced session support
|
||||||
|
* Run this script once to migrate existing database
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function migrateMultiAuth() {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
console.log("[Migration] Starting multi-auth migration...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Check if UserProvider table exists
|
||||||
|
const tableCheck = await conn.execute({
|
||||||
|
sql: "SELECT name FROM sqlite_master WHERE type='table' AND name='UserProvider'"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tableCheck.rows.length > 0) {
|
||||||
|
console.log(
|
||||||
|
"[Migration] UserProvider table already exists, skipping creation"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("[Migration] Creating UserProvider table...");
|
||||||
|
await conn.execute(`
|
||||||
|
CREATE TABLE UserProvider (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL CHECK(provider IN ('email', 'google', 'github', 'apple')),
|
||||||
|
provider_user_id TEXT,
|
||||||
|
email TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
image TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log("[Migration] Creating UserProvider indexes...");
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_user ON UserProvider (provider, provider_user_id)"
|
||||||
|
);
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_email ON UserProvider (provider, email)"
|
||||||
|
);
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_user_provider_user_id ON UserProvider (user_id)"
|
||||||
|
);
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_user_provider_provider ON UserProvider (provider)"
|
||||||
|
);
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_user_provider_email ON UserProvider (email)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Check if Session table has device columns
|
||||||
|
const sessionColumnsCheck = await conn.execute({
|
||||||
|
sql: "PRAGMA table_info(Session)"
|
||||||
|
});
|
||||||
|
const hasDeviceName = sessionColumnsCheck.rows.some(
|
||||||
|
(row: any) => row.name === "device_name"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasDeviceName) {
|
||||||
|
console.log(
|
||||||
|
"[Migration] Session table already has device columns, skipping"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("[Migration] Adding device columns to Session table...");
|
||||||
|
await conn.execute("ALTER TABLE Session ADD COLUMN device_name TEXT");
|
||||||
|
await conn.execute("ALTER TABLE Session ADD COLUMN device_type TEXT");
|
||||||
|
await conn.execute("ALTER TABLE Session ADD COLUMN browser TEXT");
|
||||||
|
await conn.execute("ALTER TABLE Session ADD COLUMN os TEXT");
|
||||||
|
|
||||||
|
// SQLite doesn't support non-constant defaults in ALTER TABLE
|
||||||
|
// Add column with NULL default, then update existing rows
|
||||||
|
await conn.execute("ALTER TABLE Session ADD COLUMN last_active_at TEXT");
|
||||||
|
|
||||||
|
// Update existing rows to set last_active_at = last_used
|
||||||
|
console.log(
|
||||||
|
"[Migration] Updating existing sessions with last_active_at..."
|
||||||
|
);
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE Session SET last_active_at = COALESCE(last_used, created_at) WHERE last_active_at IS NULL"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[Migration] Creating Session indexes...");
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_session_last_active ON Session (last_active_at)"
|
||||||
|
);
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_session_user_active ON Session (user_id, revoked, last_active_at)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Migrate existing users to UserProvider table
|
||||||
|
console.log("[Migration] Checking for users to migrate...");
|
||||||
|
const usersResult = await conn.execute({
|
||||||
|
sql: "SELECT id, email, provider, display_name, image, apple_user_string FROM User WHERE provider IS NOT NULL"
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Migration] Found ${usersResult.rows.length} users to migrate`
|
||||||
|
);
|
||||||
|
|
||||||
|
let migratedCount = 0;
|
||||||
|
for (const row of usersResult.rows) {
|
||||||
|
const user = row as any;
|
||||||
|
|
||||||
|
// Skip apple provider users (they're for Life and Lineage mobile app, not website auth)
|
||||||
|
if (user.provider === "apple") {
|
||||||
|
console.log(
|
||||||
|
`[Migration] Skipping user ${user.id} with apple provider (mobile app only)`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already migrated
|
||||||
|
const existingProvider = await conn.execute({
|
||||||
|
sql: "SELECT id FROM UserProvider WHERE user_id = ? AND provider = ?",
|
||||||
|
args: [user.id, user.provider || "email"]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingProvider.rows.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[Migration] User ${user.id} already migrated, skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine provider_user_id based on provider type
|
||||||
|
let providerUserId: string | null = null;
|
||||||
|
if (user.provider === "github") {
|
||||||
|
providerUserId = user.display_name;
|
||||||
|
} else if (user.provider === "google") {
|
||||||
|
providerUserId = user.email;
|
||||||
|
} else {
|
||||||
|
providerUserId = user.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [
|
||||||
|
uuidV4(),
|
||||||
|
user.id,
|
||||||
|
user.provider || "email",
|
||||||
|
providerUserId,
|
||||||
|
user.email,
|
||||||
|
user.display_name,
|
||||||
|
user.image
|
||||||
|
]
|
||||||
|
});
|
||||||
|
migratedCount++;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`[Migration] Failed to migrate user ${user.id}:`,
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine provider_user_id based on provider type
|
||||||
|
let providerUserId: string | null = null;
|
||||||
|
if (user.provider === "github") {
|
||||||
|
providerUserId = user.display_name;
|
||||||
|
} else if (user.provider === "google") {
|
||||||
|
providerUserId = user.email;
|
||||||
|
} else if (user.provider === "apple") {
|
||||||
|
providerUserId = user.apple_user_string;
|
||||||
|
} else {
|
||||||
|
providerUserId = user.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [
|
||||||
|
uuidV4(),
|
||||||
|
user.id,
|
||||||
|
user.provider || "email",
|
||||||
|
providerUserId,
|
||||||
|
user.email,
|
||||||
|
user.display_name,
|
||||||
|
user.image
|
||||||
|
]
|
||||||
|
});
|
||||||
|
migratedCount++;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`[Migration] Failed to migrate user ${user.id}:`,
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Migration] Migrated ${migratedCount} users successfully`);
|
||||||
|
|
||||||
|
// Step 4: Verification
|
||||||
|
console.log("[Migration] Running verification queries...");
|
||||||
|
const providerCount = await conn.execute({
|
||||||
|
sql: "SELECT COUNT(*) as count FROM UserProvider"
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[Migration] Total providers in UserProvider table: ${(providerCount.rows[0] as any).count}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const multiProviderUsers = await conn.execute({
|
||||||
|
sql: `SELECT COUNT(*) as count FROM (
|
||||||
|
SELECT user_id FROM UserProvider GROUP BY user_id HAVING COUNT(*) > 1
|
||||||
|
)`
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[Migration] Users with multiple providers: ${(multiProviderUsers.rows[0] as any).count}`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[Migration] Multi-auth migration completed successfully!");
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
migratedUsers: migratedCount,
|
||||||
|
totalProviders: (providerCount.rows[0] as any).count
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Migration] Migration failed:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration if called directly
|
||||||
|
if (require.main === module) {
|
||||||
|
migrateMultiAuth()
|
||||||
|
.then((result) => {
|
||||||
|
console.log("[Migration] Result:", result);
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[Migration] Error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
350
src/server/provider-helpers.ts
Normal file
350
src/server/provider-helpers.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import { ConnectionFactory } from "./database";
|
||||||
|
import { v4 as uuidV4 } from "uuid";
|
||||||
|
import type { UserProvider } from "~/db/types";
|
||||||
|
import { logAuditEvent } from "./audit";
|
||||||
|
import { generateProviderLinkedEmail } from "./email-templates";
|
||||||
|
import { formatDeviceDescription } from "./device-utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a new authentication provider to an existing user account
|
||||||
|
* @param userId - User ID to link provider to
|
||||||
|
* @param provider - Provider type
|
||||||
|
* @param providerData - Provider-specific data
|
||||||
|
* @param options - Optional parameters (deviceInfo, sendEmail)
|
||||||
|
* @returns Created UserProvider record
|
||||||
|
*/
|
||||||
|
export async function linkProvider(
|
||||||
|
userId: string,
|
||||||
|
provider: "email" | "google" | "github",
|
||||||
|
providerData: {
|
||||||
|
providerUserId?: string;
|
||||||
|
email?: string;
|
||||||
|
displayName?: string;
|
||||||
|
image?: string;
|
||||||
|
},
|
||||||
|
options?: {
|
||||||
|
deviceInfo?: {
|
||||||
|
deviceName?: string;
|
||||||
|
deviceType?: string;
|
||||||
|
browser?: string;
|
||||||
|
os?: string;
|
||||||
|
};
|
||||||
|
sendEmail?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<UserProvider> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
// Check if provider already linked to this user
|
||||||
|
const existing = await conn.execute({
|
||||||
|
sql: "SELECT * FROM UserProvider WHERE user_id = ? AND provider = ?",
|
||||||
|
args: [userId, provider]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
throw new Error(`Provider ${provider} already linked to this account`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if provider identity is already used by another user
|
||||||
|
if (providerData.providerUserId) {
|
||||||
|
const conflictCheck = await conn.execute({
|
||||||
|
sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND provider_user_id = ?",
|
||||||
|
args: [provider, providerData.providerUserId]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conflictCheck.rows.length > 0) {
|
||||||
|
const conflictUserId = (conflictCheck.rows[0] as any).user_id;
|
||||||
|
if (conflictUserId !== userId) {
|
||||||
|
throw new Error(
|
||||||
|
`This ${provider} account is already linked to a different user`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new provider link
|
||||||
|
const id = uuidV4();
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
provider,
|
||||||
|
providerData.providerUserId || null,
|
||||||
|
providerData.email || null,
|
||||||
|
providerData.displayName || null,
|
||||||
|
providerData.image || null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch created record
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: "SELECT * FROM UserProvider WHERE id = ?",
|
||||||
|
args: [id]
|
||||||
|
});
|
||||||
|
|
||||||
|
const userProvider = result.rows[0] as unknown as UserProvider;
|
||||||
|
|
||||||
|
// Log audit event
|
||||||
|
await logAuditEvent({
|
||||||
|
userId,
|
||||||
|
eventType: "auth.provider.linked",
|
||||||
|
eventData: {
|
||||||
|
provider,
|
||||||
|
providerEmail: providerData.email
|
||||||
|
},
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send notification email if requested and user has email
|
||||||
|
if (options?.sendEmail !== false) {
|
||||||
|
try {
|
||||||
|
// Get user email
|
||||||
|
const userResult = await conn.execute({
|
||||||
|
sql: "SELECT email FROM User WHERE id = ?",
|
||||||
|
args: [userId]
|
||||||
|
});
|
||||||
|
|
||||||
|
const userEmail = userResult.rows[0]
|
||||||
|
? ((userResult.rows[0] as any).email as string)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (userEmail) {
|
||||||
|
const deviceDescription = options?.deviceInfo
|
||||||
|
? formatDeviceDescription(options.deviceInfo)
|
||||||
|
: "Unknown Device";
|
||||||
|
|
||||||
|
const htmlContent = generateProviderLinkedEmail({
|
||||||
|
providerName: provider.charAt(0).toUpperCase() + provider.slice(1),
|
||||||
|
providerEmail: providerData.email,
|
||||||
|
linkTime: new Date().toLocaleString(),
|
||||||
|
deviceInfo: deviceDescription
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import sendEmail dynamically to avoid circular dependency
|
||||||
|
const { default: sendEmail } = await import("./email");
|
||||||
|
await sendEmail(
|
||||||
|
userEmail,
|
||||||
|
"New Authentication Provider Linked",
|
||||||
|
htmlContent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (emailError) {
|
||||||
|
// Don't fail the operation if email fails
|
||||||
|
console.error("Failed to send provider linked email:", emailError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return userProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink an authentication provider from a user account
|
||||||
|
* @param userId - User ID
|
||||||
|
* @param provider - Provider to unlink
|
||||||
|
* @throws Error if trying to remove last provider
|
||||||
|
*/
|
||||||
|
export async function unlinkProvider(
|
||||||
|
userId: string,
|
||||||
|
provider: "email" | "google" | "github"
|
||||||
|
): Promise<void> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
// Check how many providers this user has
|
||||||
|
const providersResult = await conn.execute({
|
||||||
|
sql: "SELECT COUNT(*) as count FROM UserProvider WHERE user_id = ?",
|
||||||
|
args: [userId]
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerCount = (providersResult.rows[0] as any).count;
|
||||||
|
|
||||||
|
if (providerCount <= 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Cannot remove last authentication method. Add another provider first."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete provider
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: "DELETE FROM UserProvider WHERE user_id = ? AND provider = ?",
|
||||||
|
args: [userId, provider]
|
||||||
|
});
|
||||||
|
|
||||||
|
if ((result as any).rowsAffected === 0) {
|
||||||
|
throw new Error(`Provider ${provider} not found for this user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit event
|
||||||
|
await logAuditEvent({
|
||||||
|
userId,
|
||||||
|
eventType: "auth.provider.unlinked",
|
||||||
|
eventData: {
|
||||||
|
provider
|
||||||
|
},
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all authentication providers for a user
|
||||||
|
* @param userId - User ID
|
||||||
|
* @returns Array of UserProvider records
|
||||||
|
*/
|
||||||
|
export async function getUserProviders(
|
||||||
|
userId: string
|
||||||
|
): Promise<UserProvider[]> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: "SELECT * FROM UserProvider WHERE user_id = ? ORDER BY created_at ASC",
|
||||||
|
args: [userId]
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows as unknown as UserProvider[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find user by provider and provider-specific identifier
|
||||||
|
* @param provider - Provider type
|
||||||
|
* @param providerUserId - Provider-specific user ID
|
||||||
|
* @returns User ID if found, null otherwise
|
||||||
|
*/
|
||||||
|
export async function findUserByProvider(
|
||||||
|
provider: "email" | "google" | "github",
|
||||||
|
providerUserId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND provider_user_id = ?",
|
||||||
|
args: [provider, providerUserId]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (result.rows[0] as any).user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find user by provider and email
|
||||||
|
* Used for account linking when email matches
|
||||||
|
* @param provider - Provider type
|
||||||
|
* @param email - Email address
|
||||||
|
* @returns User ID if found, null otherwise
|
||||||
|
*/
|
||||||
|
export async function findUserByProviderEmail(
|
||||||
|
provider: "email" | "google" | "github",
|
||||||
|
email: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND email = ?",
|
||||||
|
args: [provider, email]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (result.rows[0] as any).user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find any user by email across all providers
|
||||||
|
* Used for cross-provider account linking
|
||||||
|
* @param email - Email address
|
||||||
|
* @returns User ID if found, null otherwise
|
||||||
|
*/
|
||||||
|
export async function findUserByEmail(email: string): Promise<string | null> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
// First check User table
|
||||||
|
const userResult = await conn.execute({
|
||||||
|
sql: "SELECT id FROM User WHERE email = ?",
|
||||||
|
args: [email]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userResult.rows.length > 0) {
|
||||||
|
return (userResult.rows[0] as any).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check UserProvider table
|
||||||
|
const providerResult = await conn.execute({
|
||||||
|
sql: "SELECT user_id FROM UserProvider WHERE email = ? LIMIT 1",
|
||||||
|
args: [email]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (providerResult.rows.length > 0) {
|
||||||
|
return (providerResult.rows[0] as any).user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last_used_at timestamp for a provider
|
||||||
|
* Call this on successful login with that provider
|
||||||
|
* @param userId - User ID
|
||||||
|
* @param provider - Provider that was used
|
||||||
|
*/
|
||||||
|
export async function updateProviderLastUsed(
|
||||||
|
userId: string,
|
||||||
|
provider: "email" | "google" | "github"
|
||||||
|
): Promise<void> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
await conn.execute({
|
||||||
|
sql: "UPDATE UserProvider SET last_used_at = datetime('now') WHERE user_id = ? AND provider = ?",
|
||||||
|
args: [userId, provider]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user has a specific provider linked
|
||||||
|
* @param userId - User ID
|
||||||
|
* @param provider - Provider to check
|
||||||
|
* @returns true if linked, false otherwise
|
||||||
|
*/
|
||||||
|
export async function hasProvider(
|
||||||
|
userId: string,
|
||||||
|
provider: "email" | "google" | "github"
|
||||||
|
): Promise<boolean> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: "SELECT id FROM UserProvider WHERE user_id = ? AND provider = ?",
|
||||||
|
args: [userId, provider]
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provider summary for a user (for display purposes)
|
||||||
|
* @param userId - User ID
|
||||||
|
* @returns Summary of linked providers
|
||||||
|
*/
|
||||||
|
export async function getProviderSummary(userId: string): Promise<{
|
||||||
|
providers: Array<{
|
||||||
|
provider: string;
|
||||||
|
email?: string;
|
||||||
|
displayName?: string;
|
||||||
|
lastUsed: string;
|
||||||
|
}>;
|
||||||
|
count: number;
|
||||||
|
}> {
|
||||||
|
const providers = await getUserProviders(userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
providers: providers.map((p) => ({
|
||||||
|
provider: p.provider,
|
||||||
|
email: p.email || undefined,
|
||||||
|
displayName: p.display_name || undefined,
|
||||||
|
lastUsed: p.last_used_at
|
||||||
|
})),
|
||||||
|
count: providers.length
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { AUTH_CONFIG, expiryToSeconds } from "~/config";
|
|||||||
import { logAuditEvent } from "./audit";
|
import { logAuditEvent } from "./audit";
|
||||||
import type { SessionData } from "./session-config";
|
import type { SessionData } from "./session-config";
|
||||||
import { sessionConfig } from "./session-config";
|
import { sessionConfig } from "./session-config";
|
||||||
|
import { getDeviceInfo } from "./device-utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a cryptographically secure refresh token
|
* Generate a cryptographically secure refresh token
|
||||||
@@ -61,6 +62,9 @@ export async function createAuthSession(
|
|||||||
const refreshToken = generateRefreshToken();
|
const refreshToken = generateRefreshToken();
|
||||||
const tokenHash = hashRefreshToken(refreshToken);
|
const tokenHash = hashRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
// Parse device information
|
||||||
|
const deviceInfo = getDeviceInfo(event);
|
||||||
|
|
||||||
// Calculate refresh token expiration
|
// Calculate refresh token expiration
|
||||||
const refreshExpiry = rememberMe
|
const refreshExpiry = rememberMe
|
||||||
? AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG
|
? AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG
|
||||||
@@ -102,12 +106,13 @@ export async function createAuthSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert session into database
|
// Insert session into database with device metadata
|
||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: `INSERT INTO Session
|
sql: `INSERT INTO Session
|
||||||
(id, user_id, token_family, refresh_token_hash, parent_session_id,
|
(id, user_id, token_family, refresh_token_hash, parent_session_id,
|
||||||
rotation_count, expires_at, access_token_expires_at, ip_address, user_agent)
|
rotation_count, expires_at, access_token_expires_at, ip_address, user_agent,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
device_name, device_type, browser, os, last_active_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||||
args: [
|
args: [
|
||||||
sessionId,
|
sessionId,
|
||||||
userId,
|
userId,
|
||||||
@@ -118,7 +123,11 @@ export async function createAuthSession(
|
|||||||
expiresAt.toISOString(),
|
expiresAt.toISOString(),
|
||||||
accessExpiresAt.toISOString(),
|
accessExpiresAt.toISOString(),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent
|
userAgent,
|
||||||
|
deviceInfo.deviceName || null,
|
||||||
|
deviceInfo.deviceType || null,
|
||||||
|
deviceInfo.browser || null,
|
||||||
|
deviceInfo.os || null
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,7 +161,9 @@ export async function createAuthSession(
|
|||||||
sessionId,
|
sessionId,
|
||||||
tokenFamily: family,
|
tokenFamily: family,
|
||||||
rememberMe,
|
rememberMe,
|
||||||
parentSessionId
|
parentSessionId,
|
||||||
|
deviceName: deviceInfo.deviceName,
|
||||||
|
deviceType: deviceInfo.deviceType
|
||||||
},
|
},
|
||||||
success: true
|
success: true
|
||||||
});
|
});
|
||||||
@@ -299,14 +310,14 @@ async function validateSessionInDB(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last_used timestamp (fire and forget)
|
// Update last_used and last_active_at timestamps (fire and forget)
|
||||||
conn
|
conn
|
||||||
.execute({
|
.execute({
|
||||||
sql: "UPDATE Session SET last_used = datetime('now') WHERE id = ?",
|
sql: "UPDATE Session SET last_used = datetime('now'), last_active_at = datetime('now') WHERE id = ?",
|
||||||
args: [sessionId]
|
args: [sessionId]
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) =>
|
||||||
console.error("Failed to update session last_used:", err)
|
console.error("Failed to update session timestamps:", err)
|
||||||
);
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
195
src/server/session-management.ts
Normal file
195
src/server/session-management.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { ConnectionFactory } from "./database";
|
||||||
|
import type { Session } from "~/db/types";
|
||||||
|
import { formatDeviceDescription } from "./device-utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active sessions for a user
|
||||||
|
* @param userId - User ID
|
||||||
|
* @returns Array of active sessions with formatted device info
|
||||||
|
*/
|
||||||
|
export async function getUserActiveSessions(userId: string): Promise<
|
||||||
|
Array<{
|
||||||
|
sessionId: string;
|
||||||
|
deviceDescription: string;
|
||||||
|
deviceType?: string;
|
||||||
|
browser?: string;
|
||||||
|
os?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
lastActive: string;
|
||||||
|
createdAt: string;
|
||||||
|
current: boolean;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: `SELECT
|
||||||
|
id, device_name, device_type, browser, os,
|
||||||
|
ip_address, last_active_at, created_at, token_family
|
||||||
|
FROM Session
|
||||||
|
WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
|
||||||
|
ORDER BY last_active_at DESC`,
|
||||||
|
args: [userId]
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows.map((row: any) => {
|
||||||
|
const deviceInfo = {
|
||||||
|
deviceName: row.device_name,
|
||||||
|
deviceType: row.device_type,
|
||||||
|
browser: row.browser,
|
||||||
|
os: row.os
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: row.id,
|
||||||
|
deviceDescription: formatDeviceDescription(deviceInfo),
|
||||||
|
deviceType: row.device_type,
|
||||||
|
browser: row.browser,
|
||||||
|
os: row.os,
|
||||||
|
ipAddress: row.ip_address,
|
||||||
|
lastActive: row.last_active_at,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
current: false // Will be set by caller if needed
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a specific session (not entire token family)
|
||||||
|
* Useful for "logout from this device" functionality
|
||||||
|
* @param userId - User ID (for verification)
|
||||||
|
* @param sessionId - Session ID to revoke
|
||||||
|
* @throws Error if session not found or doesn't belong to user
|
||||||
|
*/
|
||||||
|
export async function revokeUserSession(
|
||||||
|
userId: string,
|
||||||
|
sessionId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
// Verify session belongs to user
|
||||||
|
const verifyResult = await conn.execute({
|
||||||
|
sql: "SELECT user_id FROM Session WHERE id = ?",
|
||||||
|
args: [sessionId]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (verifyResult.rows.length === 0) {
|
||||||
|
throw new Error("Session not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionUserId = (verifyResult.rows[0] as any).user_id;
|
||||||
|
if (sessionUserId !== userId) {
|
||||||
|
throw new Error("Session does not belong to this user");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke the session
|
||||||
|
await conn.execute({
|
||||||
|
sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
|
||||||
|
args: [sessionId]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all sessions for a user EXCEPT the current one
|
||||||
|
* Useful for "logout from all other devices"
|
||||||
|
* @param userId - User ID
|
||||||
|
* @param currentSessionId - Current session ID to keep active
|
||||||
|
* @returns Number of sessions revoked
|
||||||
|
*/
|
||||||
|
export async function revokeOtherUserSessions(
|
||||||
|
userId: string,
|
||||||
|
currentSessionId: string
|
||||||
|
): Promise<number> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: "UPDATE Session SET revoked = 1 WHERE user_id = ? AND id != ? AND revoked = 0",
|
||||||
|
args: [userId, currentSessionId]
|
||||||
|
});
|
||||||
|
|
||||||
|
return (result as any).rowsAffected || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session count by device type for a user
|
||||||
|
* @param userId - User ID
|
||||||
|
* @returns Object with counts by device type
|
||||||
|
*/
|
||||||
|
export async function getSessionCountByDevice(userId: string): Promise<{
|
||||||
|
desktop: number;
|
||||||
|
mobile: number;
|
||||||
|
tablet: number;
|
||||||
|
unknown: number;
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: `SELECT
|
||||||
|
device_type,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM Session
|
||||||
|
WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
|
||||||
|
GROUP BY device_type`,
|
||||||
|
args: [userId]
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
desktop: 0,
|
||||||
|
mobile: 0,
|
||||||
|
tablet: 0,
|
||||||
|
unknown: 0,
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const deviceType = (row as any).device_type;
|
||||||
|
const count = (row as any).count;
|
||||||
|
|
||||||
|
if (deviceType === "desktop") {
|
||||||
|
counts.desktop = count;
|
||||||
|
} else if (deviceType === "mobile") {
|
||||||
|
counts.mobile = count;
|
||||||
|
} else if (deviceType === "tablet") {
|
||||||
|
counts.tablet = count;
|
||||||
|
} else {
|
||||||
|
counts.unknown = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
counts.total += count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific device fingerprint already has an active session
|
||||||
|
* Can be used to show "You're already logged in on this device" messages
|
||||||
|
* @param userId - User ID
|
||||||
|
* @param deviceType - Device type
|
||||||
|
* @param browser - Browser name
|
||||||
|
* @param os - OS name
|
||||||
|
* @returns true if device has active session
|
||||||
|
*/
|
||||||
|
export async function hasActiveSessionOnDevice(
|
||||||
|
userId: string,
|
||||||
|
deviceType?: string,
|
||||||
|
browser?: string,
|
||||||
|
os?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: `SELECT id FROM Session
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND device_type = ?
|
||||||
|
AND browser = ?
|
||||||
|
AND os = ?
|
||||||
|
AND revoked = 0
|
||||||
|
AND expires_at > datetime('now')
|
||||||
|
LIMIT 1`,
|
||||||
|
args: [userId, deviceType || null, browser || null, os || null]
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows.length > 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user