Added user tab and oauth2, streamlined readme
This commit is contained in:
+294
-3
@@ -1,20 +1,27 @@
|
||||
import NextAuth, { type DefaultSession } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import type { OAuthConfig } from "next-auth/providers";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { cookies } from "next/headers";
|
||||
import db from "./db";
|
||||
import { config } from "./config";
|
||||
import { users } from "./db/schema";
|
||||
import { findUserByProviderSubject, findUserByEmail, createUser, getUserById } from "./models/user";
|
||||
import { createAuditEvent } from "./models/audit";
|
||||
import { decideLinkingStrategy, createLinkingToken, autoLinkOAuth, linkOAuthAuthenticated } from "./services/account-linking";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
role: string;
|
||||
provider?: string;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
interface User {
|
||||
role?: string;
|
||||
provider?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,8 +70,43 @@ function createCredentialsProvider() {
|
||||
|
||||
const credentialsProvider = createCredentialsProvider();
|
||||
|
||||
// Create OAuth providers based on configuration
|
||||
function createOAuthProviders(): OAuthConfig<any>[] {
|
||||
const providers: OAuthConfig<any>[] = [];
|
||||
|
||||
if (
|
||||
config.oauth.enabled &&
|
||||
config.oauth.clientId &&
|
||||
config.oauth.clientSecret
|
||||
) {
|
||||
const oauthProvider: OAuthConfig<any> = {
|
||||
id: "oauth2",
|
||||
name: config.oauth.providerName,
|
||||
type: "oidc",
|
||||
clientId: config.oauth.clientId,
|
||||
clientSecret: config.oauth.clientSecret,
|
||||
issuer: config.oauth.issuer ?? undefined,
|
||||
authorization: config.oauth.authorizationUrl ?? undefined,
|
||||
token: config.oauth.tokenUrl ?? undefined,
|
||||
userinfo: config.oauth.userinfoUrl ?? undefined,
|
||||
checks: ["state"],
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub ?? profile.id,
|
||||
name: profile.name ?? profile.preferred_username ?? profile.email,
|
||||
email: profile.email,
|
||||
image: profile.picture ?? profile.avatar_url ?? null,
|
||||
};
|
||||
},
|
||||
};
|
||||
providers.push(oauthProvider);
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
providers: [credentialsProvider],
|
||||
providers: [credentialsProvider, ...createOAuthProviders()],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
@@ -73,20 +115,269 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
signIn: "/login",
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
async signIn({ user, account, profile }) {
|
||||
// Credentials provider - handled by authorize function
|
||||
if (account?.provider === "credentials") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// OAuth provider sign-in
|
||||
if (!account || !user.email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if this is an OAuth linking attempt by checking the database
|
||||
const { pendingOAuthLinks } = await import("./db/schema");
|
||||
const { eq, and, gt } = await import("drizzle-orm");
|
||||
const { nowIso } = await import("./db");
|
||||
|
||||
// Find ALL non-expired pending links for this provider
|
||||
const allPendingLinks = await db.query.pendingOAuthLinks.findMany({
|
||||
where: (table, operators) =>
|
||||
operators.and(
|
||||
operators.eq(table.provider, account.provider),
|
||||
operators.gt(table.expiresAt, nowIso())
|
||||
)
|
||||
});
|
||||
|
||||
// Security: Match by userId to prevent race condition where User B could
|
||||
// overwrite User A's pending link. We verify by checking which user exists.
|
||||
let pendingLink = null;
|
||||
if (allPendingLinks.length === 1) {
|
||||
// Common case: only one user is linking this provider right now
|
||||
pendingLink = allPendingLinks[0];
|
||||
} else if (allPendingLinks.length > 1) {
|
||||
// Race condition detected: multiple users linking same provider
|
||||
// This shouldn't happen with unique index, but handle gracefully
|
||||
// Find the user whose email matches their stored email
|
||||
for (const link of allPendingLinks) {
|
||||
const existingUser = await getUserById(link.userId);
|
||||
if (existingUser && existingUser.email === link.userEmail) {
|
||||
pendingLink = link;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingLink) {
|
||||
try {
|
||||
const userId = pendingLink.userId;
|
||||
const existingUser = await getUserById(userId);
|
||||
|
||||
if (existingUser) {
|
||||
// Security: Validate OAuth email matches the authenticated user's stored email
|
||||
// This prevents users from linking arbitrary OAuth accounts to their credentials account
|
||||
if (user.email && existingUser.email !== pendingLink.userEmail) {
|
||||
console.error(`OAuth linking rejected: user email mismatch. Expected ${pendingLink.userEmail}, got ${existingUser.email}`);
|
||||
|
||||
// Clean up the pending link
|
||||
await db.delete(pendingOAuthLinks).where(eq(pendingOAuthLinks.id, pendingLink.id));
|
||||
|
||||
// Audit log for security event
|
||||
await createAuditEvent({
|
||||
userId: existingUser.id,
|
||||
action: "oauth_link_rejected",
|
||||
entityType: "user",
|
||||
entityId: existingUser.id,
|
||||
summary: `OAuth linking rejected: email mismatch`,
|
||||
data: JSON.stringify({
|
||||
provider: account.provider,
|
||||
expectedEmail: pendingLink.userEmail,
|
||||
actualEmail: existingUser.email
|
||||
})
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// User is already authenticated - auto-link
|
||||
const linked = await linkOAuthAuthenticated(
|
||||
userId,
|
||||
account.provider,
|
||||
account.providerAccountId,
|
||||
user.image
|
||||
);
|
||||
|
||||
if (linked) {
|
||||
// Reload user from database to get updated data
|
||||
const updatedUser = await getUserById(userId);
|
||||
|
||||
if (updatedUser) {
|
||||
user.id = updatedUser.id.toString();
|
||||
user.role = updatedUser.role;
|
||||
user.provider = updatedUser.provider;
|
||||
user.email = updatedUser.email;
|
||||
user.name = updatedUser.name;
|
||||
|
||||
// Delete the pending link
|
||||
await db.delete(pendingOAuthLinks).where(eq(pendingOAuthLinks.id, pendingLink.id));
|
||||
|
||||
// Audit log
|
||||
await createAuditEvent({
|
||||
userId: updatedUser.id,
|
||||
action: "account_linked",
|
||||
entityType: "user",
|
||||
entityId: updatedUser.id,
|
||||
summary: `OAuth account linked while authenticated: ${account.provider}`,
|
||||
data: JSON.stringify({ provider: account.provider, email: user.email })
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error processing pending link:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if OAuth account already exists
|
||||
const existingOAuthUser = await findUserByProviderSubject(
|
||||
account.provider,
|
||||
account.providerAccountId
|
||||
);
|
||||
|
||||
if (existingOAuthUser) {
|
||||
// Existing OAuth user - update user object and allow sign-in
|
||||
user.id = existingOAuthUser.id.toString();
|
||||
user.role = existingOAuthUser.role;
|
||||
user.provider = existingOAuthUser.provider;
|
||||
|
||||
// Audit log
|
||||
await createAuditEvent({
|
||||
userId: existingOAuthUser.id,
|
||||
action: "oauth_signin",
|
||||
entityType: "user",
|
||||
entityId: existingOAuthUser.id,
|
||||
summary: `User signed in via ${account.provider}`,
|
||||
data: JSON.stringify({ provider: account.provider })
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Determine linking strategy
|
||||
const decision = await decideLinkingStrategy(
|
||||
account.provider,
|
||||
account.providerAccountId,
|
||||
user.email
|
||||
);
|
||||
|
||||
if (decision.action === "auto_link" && decision.userId) {
|
||||
// Auto-link OAuth to existing account without password
|
||||
const linked = await autoLinkOAuth(
|
||||
decision.userId,
|
||||
account.provider,
|
||||
account.providerAccountId,
|
||||
user.image
|
||||
);
|
||||
|
||||
if (linked) {
|
||||
const linkedUser = await getUserById(decision.userId);
|
||||
if (linkedUser) {
|
||||
user.id = linkedUser.id.toString();
|
||||
user.role = linkedUser.role;
|
||||
user.provider = linkedUser.provider;
|
||||
|
||||
// Audit log
|
||||
await createAuditEvent({
|
||||
userId: linkedUser.id,
|
||||
action: "account_linked",
|
||||
entityType: "user",
|
||||
entityId: linkedUser.id,
|
||||
summary: `OAuth account auto-linked: ${account.provider}`,
|
||||
data: JSON.stringify({ provider: account.provider, email: user.email })
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (decision.action === "require_manual_link" && decision.userId) {
|
||||
// Email collision - require manual linking with password verification
|
||||
const linkingToken = await createLinkingToken(
|
||||
decision.userId,
|
||||
account.provider,
|
||||
account.providerAccountId,
|
||||
user.email
|
||||
);
|
||||
|
||||
// Redirect to link-account page with token
|
||||
throw new Error(`LINKING_REQUIRED:${linkingToken}`);
|
||||
}
|
||||
|
||||
// New OAuth user - create account (defaults to admin role)
|
||||
const newUser = await createUser({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
provider: account.provider,
|
||||
subject: account.providerAccountId,
|
||||
avatar_url: user.image
|
||||
});
|
||||
|
||||
user.id = newUser.id.toString();
|
||||
user.role = newUser.role;
|
||||
user.provider = newUser.provider;
|
||||
|
||||
// Audit log
|
||||
await createAuditEvent({
|
||||
userId: newUser.id,
|
||||
action: "oauth_signup",
|
||||
entityType: "user",
|
||||
entityId: newUser.id,
|
||||
summary: `New user created via ${account.provider} OAuth`,
|
||||
data: JSON.stringify({ provider: account.provider, email: user.email })
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("OAuth sign-in error:", error);
|
||||
|
||||
// Audit log for failed OAuth attempts
|
||||
try {
|
||||
await createAuditEvent({
|
||||
userId: null,
|
||||
action: "oauth_signin_failed",
|
||||
entityType: "user",
|
||||
entityId: null,
|
||||
summary: `OAuth sign-in failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
data: JSON.stringify({
|
||||
provider: account?.provider,
|
||||
email: user?.email,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.error("Failed to create audit log for OAuth error:", auditError);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async jwt({ token, user, account }) {
|
||||
// On sign in, add user info to token
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.email = user.email;
|
||||
token.role = user.role ?? "user";
|
||||
token.provider = account?.provider ?? user.provider ?? "credentials";
|
||||
token.image = user.image;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
// Add user info from token to session
|
||||
if (session.user) {
|
||||
if (session.user && token.id) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.role = token.role as string;
|
||||
session.user.provider = token.provider as string;
|
||||
|
||||
// Fetch current avatar from database to ensure it's always up-to-date
|
||||
const userId = Number(token.id);
|
||||
const currentUser = await getUserById(userId);
|
||||
session.user.image = currentUser?.avatar_url ?? (token.image as string | null | undefined);
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
@@ -157,6 +157,17 @@ export const config = {
|
||||
},
|
||||
get adminPassword() {
|
||||
return getAdminCredentials().password;
|
||||
},
|
||||
oauth: {
|
||||
enabled: process.env.OAUTH_ENABLED === "true",
|
||||
providerName: process.env.OAUTH_PROVIDER_NAME ?? "OAuth2",
|
||||
clientId: process.env.OAUTH_CLIENT_ID ?? null,
|
||||
clientSecret: process.env.OAUTH_CLIENT_SECRET ?? null,
|
||||
issuer: process.env.OAUTH_ISSUER ?? null,
|
||||
authorizationUrl: process.env.OAUTH_AUTHORIZATION_URL ?? null,
|
||||
tokenUrl: process.env.OAUTH_TOKEN_URL ?? null,
|
||||
userinfoUrl: process.env.OAUTH_USERINFO_URL ?? null,
|
||||
allowAutoLinking: process.env.OAUTH_ALLOW_AUTO_LINKING === "true",
|
||||
}
|
||||
};
|
||||
|
||||
@@ -174,3 +185,25 @@ export function validateProductionConfig() {
|
||||
const ___ = config.adminPassword;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list of enabled OAuth providers based on configuration.
|
||||
* Only includes providers that have complete credentials configured.
|
||||
*/
|
||||
export function getEnabledOAuthProviders(): Array<{id: string; name: string}> {
|
||||
const providers: Array<{id: string; name: string}> = [];
|
||||
|
||||
if (
|
||||
config.oauth.enabled &&
|
||||
config.oauth.clientId &&
|
||||
config.oauth.clientSecret &&
|
||||
config.oauth.issuer
|
||||
) {
|
||||
providers.push({
|
||||
id: "oauth2",
|
||||
name: config.oauth.providerName
|
||||
});
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,18 @@ export const oauthStates = sqliteTable(
|
||||
})
|
||||
);
|
||||
|
||||
export const pendingOAuthLinks = sqliteTable("pending_oauth_links", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
provider: text("provider", { length: 50 }).notNull(),
|
||||
userEmail: text("user_email").notNull(), // Email of the user who initiated linking
|
||||
createdAt: text("created_at").notNull(),
|
||||
expiresAt: text("expires_at").notNull()
|
||||
}, (table) => ({
|
||||
// Ensure only one pending link per user per provider (prevents race conditions)
|
||||
userProviderUnique: uniqueIndex("pending_oauth_user_provider_unique").on(table.userId, table.provider)
|
||||
}));
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
|
||||
+20
-1
@@ -1,4 +1,4 @@
|
||||
import db, { toIso } from "../db";
|
||||
import db, { toIso, nowIso } from "../db";
|
||||
import { auditEvents } from "../db/schema";
|
||||
import { desc } from "drizzle-orm";
|
||||
|
||||
@@ -29,3 +29,22 @@ export async function listAuditEvents(limit = 100): Promise<AuditEvent[]> {
|
||||
created_at: toIso(event.createdAt)!
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createAuditEvent(data: {
|
||||
userId: number | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId?: number | null;
|
||||
summary?: string | null;
|
||||
data?: string | null;
|
||||
}): Promise<void> {
|
||||
await db.insert(auditEvents).values({
|
||||
userId: data.userId,
|
||||
action: data.action,
|
||||
entityType: data.entityType,
|
||||
entityId: data.entityId ?? null,
|
||||
summary: data.summary ?? null,
|
||||
data: data.data ?? null,
|
||||
createdAt: nowIso(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function createUser(data: {
|
||||
passwordHash?: string | null;
|
||||
}): Promise<User> {
|
||||
const now = nowIso();
|
||||
const role = data.role ?? "user";
|
||||
const role = data.role ?? "admin"; // All users are admin by default
|
||||
const email = data.email.trim().toLowerCase();
|
||||
|
||||
const [user] = await db
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { config } from "../config";
|
||||
import { findUserByEmail, findUserByProviderSubject, getUserById } from "../models/user";
|
||||
import db from "../db";
|
||||
import { users } from "../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nowIso } from "../db";
|
||||
|
||||
const LINKING_TOKEN_EXPIRY = 5 * 60; // 5 minutes in seconds
|
||||
|
||||
export type LinkingDecision = {
|
||||
action: "auto_link" | "require_manual_link" | "create_new" | "signin_existing";
|
||||
userId?: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type LinkingTokenPayload = {
|
||||
userId: number;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
email: string;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines how to handle an OAuth sign-in attempt
|
||||
*/
|
||||
export async function decideLinkingStrategy(
|
||||
provider: string,
|
||||
providerAccountId: string,
|
||||
email: string
|
||||
): Promise<LinkingDecision> {
|
||||
// Check if OAuth account already exists
|
||||
const existingOAuthUser = await findUserByProviderSubject(provider, providerAccountId);
|
||||
if (existingOAuthUser) {
|
||||
return {
|
||||
action: "signin_existing",
|
||||
userId: existingOAuthUser.id,
|
||||
reason: "OAuth account already linked"
|
||||
};
|
||||
}
|
||||
|
||||
// Check if email matches existing user
|
||||
const existingEmailUser = await findUserByEmail(email);
|
||||
if (!existingEmailUser) {
|
||||
return {
|
||||
action: "create_new",
|
||||
reason: "No existing account with this email"
|
||||
};
|
||||
}
|
||||
|
||||
// User exists with this email
|
||||
if (existingEmailUser.password_hash) {
|
||||
// Has password - require manual linking with password verification
|
||||
return {
|
||||
action: "require_manual_link",
|
||||
userId: existingEmailUser.id,
|
||||
reason: "Account has password - requires manual linking"
|
||||
};
|
||||
}
|
||||
|
||||
// No password (OAuth-only account)
|
||||
if (config.oauth.allowAutoLinking) {
|
||||
return {
|
||||
action: "auto_link",
|
||||
userId: existingEmailUser.id,
|
||||
reason: "Account has no password - auto-linking enabled"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: "require_manual_link",
|
||||
userId: existingEmailUser.id,
|
||||
reason: "Auto-linking disabled"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary linking token (5-minute expiry)
|
||||
*/
|
||||
export async function createLinkingToken(
|
||||
userId: number,
|
||||
provider: string,
|
||||
providerAccountId: string,
|
||||
email: string
|
||||
): Promise<string> {
|
||||
const secret = new TextEncoder().encode(config.sessionSecret);
|
||||
|
||||
const token = await new SignJWT({
|
||||
userId,
|
||||
provider,
|
||||
providerAccountId,
|
||||
email
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime(`${LINKING_TOKEN_EXPIRY}s`)
|
||||
.setIssuedAt()
|
||||
.sign(secret);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode linking token
|
||||
*/
|
||||
export async function verifyLinkingToken(token: string): Promise<LinkingTokenPayload | null> {
|
||||
try {
|
||||
const secret = new TextEncoder().encode(config.sessionSecret);
|
||||
const { payload } = await jwtVerify(token, secret);
|
||||
|
||||
return {
|
||||
userId: payload.userId as number,
|
||||
provider: payload.provider as string,
|
||||
providerAccountId: payload.providerAccountId as string,
|
||||
email: payload.email as string,
|
||||
exp: payload.exp as number
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Token verification failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify password and link OAuth account to existing user
|
||||
*/
|
||||
export async function verifyAndLinkOAuth(
|
||||
userId: number,
|
||||
password: string,
|
||||
provider: string,
|
||||
providerAccountId: string
|
||||
): Promise<boolean> {
|
||||
const user = await getUserById(userId);
|
||||
if (!user || !user.password_hash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = bcrypt.compareSync(password, user.password_hash);
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update user to link OAuth
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
provider,
|
||||
subject: providerAccountId,
|
||||
updatedAt: nowIso()
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-link OAuth account (for users without passwords)
|
||||
*/
|
||||
export async function autoLinkOAuth(
|
||||
userId: number,
|
||||
provider: string,
|
||||
providerAccountId: string,
|
||||
avatarUrl?: string | null
|
||||
): Promise<boolean> {
|
||||
const user = await getUserById(userId);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't auto-link if user has a password (unless explicitly called for authenticated linking)
|
||||
// This check is bypassed when called from the authenticated linking flow
|
||||
if (user.password_hash && !process.env.OAUTH_ALLOW_AUTO_LINKING) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update user to link OAuth
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
provider,
|
||||
subject: providerAccountId,
|
||||
avatarUrl: avatarUrl ?? user.avatar_url,
|
||||
updatedAt: nowIso()
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link OAuth account for an already-authenticated user
|
||||
* This bypasses the password check since the user is already authenticated
|
||||
*/
|
||||
export async function linkOAuthAuthenticated(
|
||||
userId: number,
|
||||
provider: string,
|
||||
providerAccountId: string,
|
||||
avatarUrl?: string | null
|
||||
): Promise<boolean> {
|
||||
const user = await getUserById(userId);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update user to link OAuth
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
provider,
|
||||
subject: providerAccountId,
|
||||
avatarUrl: avatarUrl ?? user.avatar_url,
|
||||
updatedAt: nowIso()
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user