Files
DiscordClone/convex/auth.ts
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
2026-04-14 09:02:14 -05:00

537 lines
15 KiB
TypeScript

import { query, mutation, internalQuery, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { getPublicStorageUrl } from "./storageUrl";
import { getRolesForUser } from "./roles";
async function sha256Hex(input: string): Promise<string> {
const buffer = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(input)
);
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
// Get salt for a username (returns fake salt for non-existent users)
export const getSalt = query({
args: { username: v.string() },
returns: v.object({ salt: v.string() }),
handler: async (ctx, args) => {
const user = await ctx.db
.query("userProfiles")
.withIndex("by_username", (q) => q.eq("username", args.username))
.unique();
if (user) {
return { salt: user.clientSalt };
}
// Generate deterministic fake salt for non-existent users (privacy)
const fakeSalt = await sha256Hex("SERVER_SECRET_KEY" + args.username);
return { salt: fakeSalt };
},
});
// Verify user credentials (DAK comparison)
export const verifyUser = mutation({
args: {
username: v.string(),
dak: v.string(),
},
returns: v.union(
v.object({
success: v.boolean(),
userId: v.string(),
encryptedMK: v.string(),
encryptedPrivateKeys: v.string(),
publicKey: v.string(),
}),
v.object({ error: v.string() })
),
handler: async (ctx, args) => {
const user = await ctx.db
.query("userProfiles")
.withIndex("by_username", (q) => q.eq("username", args.username))
.unique();
if (!user) {
return { error: "Invalid credentials" };
}
const hashedDAK = await sha256Hex(args.dak);
if (hashedDAK === user.hashedAuthKey) {
return {
success: true,
userId: user._id,
encryptedMK: user.encryptedMasterKey,
encryptedPrivateKeys: user.encryptedPrivateKeys,
publicKey: user.publicIdentityKey,
};
}
return { error: "Invalid credentials" };
},
});
// Register new user with crypto keys
export const createUserWithProfile = mutation({
args: {
username: v.string(),
salt: v.string(),
encryptedMK: v.string(),
hak: v.string(),
publicKey: v.string(),
signingKey: v.string(),
encryptedPrivateKeys: v.string(),
inviteCode: v.optional(v.string()),
},
returns: v.union(
v.object({ success: v.boolean(), userId: v.string() }),
v.object({ error: v.string() })
),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("userProfiles")
.withIndex("by_username", (q) => q.eq("username", args.username))
.unique();
if (existing) {
return { error: "Username taken" };
}
const isFirstUser =
(await ctx.db.query("userProfiles").first()) === null;
if (!isFirstUser) {
if (!args.inviteCode) {
return { error: "Invite code required" };
}
const inviteCode = args.inviteCode!;
const invite = await ctx.db
.query("invites")
.withIndex("by_code", (q) => q.eq("code", inviteCode))
.unique();
if (!invite) {
return { error: "Invalid invite code" };
}
if (invite.expiresAt && Date.now() > invite.expiresAt) {
return { error: "Invite expired" };
}
if (
invite.maxUses !== undefined &&
invite.maxUses !== null &&
invite.uses >= invite.maxUses
) {
return { error: "Invite max uses reached" };
}
await ctx.db.patch(invite._id, { uses: invite.uses + 1 });
}
const userId = await ctx.db.insert("userProfiles", {
username: args.username,
clientSalt: args.salt,
encryptedMasterKey: args.encryptedMK,
hashedAuthKey: args.hak,
publicIdentityKey: args.publicKey,
publicSigningKey: args.signingKey,
encryptedPrivateKeys: args.encryptedPrivateKeys,
isAdmin: isFirstUser,
});
if (isFirstUser) {
const everyoneRoleId = await ctx.db.insert("roles", {
name: "@everyone",
color: "#99aab5",
position: 0,
permissions: {
create_invite: true,
embed_links: true,
attach_files: true,
},
isHoist: false,
});
const ownerRoleId = await ctx.db.insert("roles", {
name: "Owner",
color: "#e91e63",
position: 100,
permissions: {
manage_channels: true,
manage_roles: true,
manage_messages: true,
manage_nicknames: true,
create_invite: true,
embed_links: true,
attach_files: true,
},
isHoist: true,
});
await ctx.db.insert("userRoles", { userId, roleId: everyoneRoleId });
await ctx.db.insert("userRoles", { userId, roleId: ownerRoleId });
} else {
const everyoneRole = await ctx.db
.query("roles")
.filter((q) => q.eq(q.field("name"), "@everyone"))
.first();
if (everyoneRole) {
await ctx.db.insert("userRoles", {
userId,
roleId: everyoneRole._id,
});
}
}
return { success: true, userId };
},
});
// Get all users' public keys
export const getPublicKeys = query({
args: {},
returns: v.array(
v.object({
id: v.string(),
username: v.string(),
public_identity_key: v.string(),
status: v.optional(v.string()),
displayName: v.optional(v.string()),
avatarUrl: v.optional(v.union(v.string(), v.null())),
aboutMe: v.optional(v.string()),
customStatus: v.optional(v.string()),
joinSoundUrl: v.optional(v.union(v.string(), v.null())),
accentColor: v.optional(v.string()),
})
),
handler: async (ctx) => {
const users = await ctx.db.query("userProfiles").collect();
const results = [];
for (const u of users) {
let avatarUrl: string | null = null;
if (u.avatarStorageId) {
avatarUrl = await getPublicStorageUrl(ctx, u.avatarStorageId);
}
let joinSoundUrl: string | null = null;
if (u.joinSoundStorageId) {
joinSoundUrl = await getPublicStorageUrl(ctx, u.joinSoundStorageId);
}
results.push({
id: u._id,
username: u.username,
public_identity_key: u.publicIdentityKey,
status: u.status || "offline",
displayName: u.displayName,
avatarUrl,
aboutMe: u.aboutMe,
customStatus: u.customStatus,
joinSoundUrl,
accentColor: u.accentColor,
});
}
return results;
},
});
// Update user profile (aboutMe, avatar, customStatus)
export const updateProfile = mutation({
args: {
userId: v.id("userProfiles"),
displayName: v.optional(v.string()),
aboutMe: v.optional(v.string()),
avatarStorageId: v.optional(v.id("_storage")),
customStatus: v.optional(v.string()),
joinSoundStorageId: v.optional(v.id("_storage")),
removeJoinSound: v.optional(v.boolean()),
accentColor: v.optional(v.string()),
},
returns: v.null(),
handler: async (ctx, args) => {
const patch: Record<string, unknown> = {};
if (args.displayName !== undefined) patch.displayName = args.displayName;
if (args.aboutMe !== undefined) patch.aboutMe = args.aboutMe;
if (args.avatarStorageId !== undefined) patch.avatarStorageId = args.avatarStorageId;
if (args.customStatus !== undefined) patch.customStatus = args.customStatus;
if (args.joinSoundStorageId !== undefined) patch.joinSoundStorageId = args.joinSoundStorageId;
if (args.removeJoinSound) patch.joinSoundStorageId = undefined;
if (args.accentColor !== undefined) patch.accentColor = args.accentColor;
await ctx.db.patch(args.userId, patch);
return null;
},
});
// Get the current user's join sound URL
export const getMyJoinSoundUrl = query({
args: { userId: v.id("userProfiles") },
returns: v.union(v.string(), v.null()),
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
if (!user?.joinSoundStorageId) return null;
return await getPublicStorageUrl(ctx, user.joinSoundStorageId);
},
});
// Update user status
export const updateStatus = mutation({
args: {
userId: v.id("userProfiles"),
status: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, { status: args.status });
return null;
},
});
// Get encrypted private keys + public signing key for recovery
export const getRecoveryData = query({
args: { username: v.string() },
returns: v.union(
v.object({
encryptedPrivateKeys: v.string(),
publicSigningKey: v.string(),
}),
v.object({ error: v.string() })
),
handler: async (ctx, args) => {
const user = await ctx.db
.query("userProfiles")
.withIndex("by_username", (q) => q.eq("username", args.username))
.unique();
if (!user) {
return { error: "User not found" };
}
return {
encryptedPrivateKeys: user.encryptedPrivateKeys,
publicSigningKey: user.publicSigningKey,
};
},
});
// Internal: get userId + publicSigningKey for recovery action verification
export const getUserForRecovery = internalQuery({
args: { username: v.string() },
returns: v.union(
v.object({
userId: v.id("userProfiles"),
publicSigningKey: v.string(),
}),
v.null()
),
handler: async (ctx, args) => {
const user = await ctx.db
.query("userProfiles")
.withIndex("by_username", (q) => q.eq("username", args.username))
.unique();
if (!user) return null;
return {
userId: user._id,
publicSigningKey: user.publicSigningKey,
};
},
});
// Set nickname (displayName) for a user
export const setNickname = mutation({
args: {
actorUserId: v.id("userProfiles"),
targetUserId: v.id("userProfiles"),
displayName: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
// Self-changes are always allowed
if (args.actorUserId !== args.targetUserId) {
const roles = await getRolesForUser(ctx, args.actorUserId);
const canManage = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.["manage_nicknames"]
);
if (!canManage) {
throw new Error("You don't have permission to change other users' nicknames");
}
}
const trimmed = args.displayName.trim();
await ctx.db.patch(args.targetUserId, {
displayName: trimmed || undefined,
});
return null;
},
});
// Delete a user and all their associated data (admin only)
export const deleteUser = mutation({
args: {
requestingUserId: v.id("userProfiles"),
targetUserId: v.id("userProfiles"),
},
returns: v.object({ success: v.boolean(), error: v.optional(v.string()) }),
handler: async (ctx, args) => {
// Verify requesting user is admin
const requester = await ctx.db.get(args.requestingUserId);
if (!requester || !requester.isAdmin) {
return { success: false, error: "Only admins can delete users" };
}
// Prevent self-deletion
if (args.requestingUserId === args.targetUserId) {
return { success: false, error: "Cannot delete your own account" };
}
const target = await ctx.db.get(args.targetUserId);
if (!target) {
return { success: false, error: "User not found" };
}
// Prevent deleting other admins
if (target.isAdmin) {
return { success: false, error: "Cannot delete another admin" };
}
// Delete reactions made by this user (before messages, using index)
const userReactions = await ctx.db
.query("messageReactions")
.withIndex("by_user", (q) => q.eq("userId", args.targetUserId))
.collect();
for (const r of userReactions) {
await ctx.db.delete(r._id);
}
// Delete all messages by this user (using index)
const messages = await ctx.db
.query("messages")
.withIndex("by_sender", (q) => q.eq("senderId", args.targetUserId))
.collect();
for (const msg of messages) {
// Delete reactions on this message
const reactions = await ctx.db
.query("messageReactions")
.withIndex("by_message", (q) => q.eq("messageId", msg._id))
.collect();
for (const r of reactions) {
await ctx.db.delete(r._id);
}
await ctx.db.delete(msg._id);
}
// Delete channel keys
const channelKeys = await ctx.db
.query("channelKeys")
.withIndex("by_user", (q) => q.eq("userId", args.targetUserId))
.collect();
for (const ck of channelKeys) {
await ctx.db.delete(ck._id);
}
// Delete role assignments
const userRoles = await ctx.db
.query("userRoles")
.withIndex("by_user", (q) => q.eq("userId", args.targetUserId))
.collect();
for (const ur of userRoles) {
await ctx.db.delete(ur._id);
}
// Delete DM participations
const dmParts = await ctx.db
.query("dmParticipants")
.withIndex("by_user", (q) => q.eq("userId", args.targetUserId))
.collect();
for (const dp of dmParts) {
await ctx.db.delete(dp._id);
}
// Delete typing indicators
const typingIndicators = await ctx.db
.query("typingIndicators")
.withIndex("by_user", (q) => q.eq("userId", args.targetUserId))
.collect();
for (const ti of typingIndicators) {
await ctx.db.delete(ti._id);
}
// Delete voice states
const voiceStates = await ctx.db
.query("voiceStates")
.withIndex("by_user", (q) => q.eq("userId", args.targetUserId))
.collect();
for (const vs of voiceStates) {
await ctx.db.delete(vs._id);
}
// Delete read states
const readStates = await ctx.db
.query("channelReadState")
.withIndex("by_user", (q) => q.eq("userId", args.targetUserId))
.collect();
for (const rs of readStates) {
await ctx.db.delete(rs._id);
}
// Delete invites created by this user
const invites = await ctx.db
.query("invites")
.withIndex("by_creator", (q) => q.eq("createdBy", args.targetUserId))
.collect();
for (const inv of invites) {
await ctx.db.delete(inv._id);
}
// Delete custom emojis uploaded by this user
const emojis = await ctx.db
.query("customEmojis")
.withIndex("by_uploader", (q) => q.eq("uploadedBy", args.targetUserId))
.collect();
for (const emoji of emojis) {
await ctx.storage.delete(emoji.storageId);
await ctx.db.delete(emoji._id);
}
// Delete avatar from storage if exists
if (target.avatarStorageId) {
await ctx.storage.delete(target.avatarStorageId);
}
// Delete join sound from storage if exists
if (target.joinSoundStorageId) {
await ctx.storage.delete(target.joinSoundStorageId);
}
// Delete the user profile
await ctx.db.delete(args.targetUserId);
return { success: true };
},
});
// Internal: update credentials after password reset
export const updateCredentials = internalMutation({
args: {
userId: v.id("userProfiles"),
clientSalt: v.string(),
encryptedMasterKey: v.string(),
hashedAuthKey: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, {
clientSalt: args.clientSalt,
encryptedMasterKey: args.encryptedMasterKey,
hashedAuthKey: args.hashedAuthKey,
});
return null;
},
});