Version Bump 1.0.40
All checks were successful
Build and Release / build-and-release (push) Successful in 19m24s
All checks were successful
Build and Release / build-and-release (push) Successful in 19m24s
This commit is contained in:
@@ -8,7 +8,7 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 27
|
versionCode 27
|
||||||
versionName "1.0.39"
|
versionName "1.0.40"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/android",
|
"name": "@discord-clone/android",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.39",
|
"version": "1.0.40",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"cap:sync": "npx cap sync",
|
"cap:sync": "npx cap sync",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.39",
|
"version": "1.0.40",
|
||||||
"description": "Brycord - Electron app",
|
"description": "Brycord - Electron app",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/web",
|
"name": "@discord-clone/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.39",
|
"version": "1.0.40",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
145
convex/auth.ts
145
convex/auth.ts
@@ -367,6 +367,151 @@ export const setNickname = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
// Internal: update credentials after password reset
|
||||||
export const updateCredentials = internalMutation({
|
export const updateCredentials = internalMutation({
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ export default defineSchema({
|
|||||||
editedAt: v.optional(v.number()),
|
editedAt: v.optional(v.number()),
|
||||||
pinned: v.optional(v.boolean()),
|
pinned: v.optional(v.boolean()),
|
||||||
}).index("by_channel", ["channelId"])
|
}).index("by_channel", ["channelId"])
|
||||||
.index("by_channel_pinned", ["channelId", "pinned"]),
|
.index("by_channel_pinned", ["channelId", "pinned"])
|
||||||
|
.index("by_sender", ["senderId"]),
|
||||||
|
|
||||||
messageReactions: defineTable({
|
messageReactions: defineTable({
|
||||||
messageId: v.id("messages"),
|
messageId: v.id("messages"),
|
||||||
@@ -52,7 +53,8 @@ export default defineSchema({
|
|||||||
emoji: v.string(),
|
emoji: v.string(),
|
||||||
})
|
})
|
||||||
.index("by_message", ["messageId"])
|
.index("by_message", ["messageId"])
|
||||||
.index("by_message_user_emoji", ["messageId", "userId", "emoji"]),
|
.index("by_message_user_emoji", ["messageId", "userId", "emoji"])
|
||||||
|
.index("by_user", ["userId"]),
|
||||||
|
|
||||||
channelKeys: defineTable({
|
channelKeys: defineTable({
|
||||||
channelId: v.id("channels"),
|
channelId: v.id("channels"),
|
||||||
@@ -88,7 +90,8 @@ export default defineSchema({
|
|||||||
uses: v.number(),
|
uses: v.number(),
|
||||||
expiresAt: v.optional(v.number()), // timestamp
|
expiresAt: v.optional(v.number()), // timestamp
|
||||||
keyVersion: v.number(),
|
keyVersion: v.number(),
|
||||||
}).index("by_code", ["code"]),
|
}).index("by_code", ["code"])
|
||||||
|
.index("by_creator", ["createdBy"]),
|
||||||
|
|
||||||
dmParticipants: defineTable({
|
dmParticipants: defineTable({
|
||||||
channelId: v.id("channels"),
|
channelId: v.id("channels"),
|
||||||
@@ -102,7 +105,8 @@ export default defineSchema({
|
|||||||
userId: v.id("userProfiles"),
|
userId: v.id("userProfiles"),
|
||||||
username: v.string(),
|
username: v.string(),
|
||||||
expiresAt: v.number(), // timestamp
|
expiresAt: v.number(), // timestamp
|
||||||
}).index("by_channel", ["channelId"]),
|
}).index("by_channel", ["channelId"])
|
||||||
|
.index("by_user", ["userId"]),
|
||||||
|
|
||||||
voiceStates: defineTable({
|
voiceStates: defineTable({
|
||||||
channelId: v.id("channels"),
|
channelId: v.id("channels"),
|
||||||
@@ -138,6 +142,8 @@ export default defineSchema({
|
|||||||
name: v.string(),
|
name: v.string(),
|
||||||
storageId: v.id("_storage"),
|
storageId: v.id("_storage"),
|
||||||
uploadedBy: v.id("userProfiles"),
|
uploadedBy: v.id("userProfiles"),
|
||||||
|
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
}).index("by_name", ["name"]),
|
}).index("by_name", ["name"])
|
||||||
|
.index("by_uploader", ["uploadedBy"]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.39",
|
"version": "1.0.40",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/App.jsx",
|
"main": "src/App.jsx",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -493,10 +493,31 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isCurrentUserAdmin = members.find(m => m.id === userId)?.roles?.some(r => r.name === 'Owner');
|
||||||
|
|
||||||
|
const handleDeleteUser = async (targetUserId, targetUsername) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete "${targetUsername}" and ALL their messages? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
const result = await convex.mutation(api.auth.deleteUser, {
|
||||||
|
requestingUserId: userId,
|
||||||
|
targetUserId,
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || 'Failed to delete user.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Delete user error:', e);
|
||||||
|
alert('Failed to delete user. See console.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderMembersTab = () => (
|
const renderMembersTab = () => (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ color: 'var(--header-primary)' }}>Members</h2>
|
<h2 style={{ color: 'var(--header-primary)' }}>Members</h2>
|
||||||
{members.map(m => (
|
{members.map(m => {
|
||||||
|
const isOwner = m.roles?.some(r => r.name === 'Owner');
|
||||||
|
const isSelf = m.id === userId;
|
||||||
|
return (
|
||||||
<div key={m.id} style={{ display: 'flex', alignItems: 'center', padding: '10px', borderBottom: '1px solid var(--border-subtle)' }}>
|
<div key={m.id} style={{ display: 'flex', alignItems: 'center', padding: '10px', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#5865F2', marginRight: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--header-primary)' }}>
|
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#5865F2', marginRight: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--header-primary)' }}>
|
||||||
{m.username[0].toUpperCase()}
|
{m.username[0].toUpperCase()}
|
||||||
@@ -511,9 +532,8 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canManageRoles && (
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
{canManageRoles && editableRoles.map(r => {
|
||||||
{editableRoles.map(r => {
|
|
||||||
const hasRole = m.roles?.some(ur => ur._id === r._id);
|
const hasRole = m.roles?.some(ur => ur._id === r._id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -529,10 +549,28 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
{isCurrentUserAdmin && !isSelf && !isOwner && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUser(m.id, m.username)}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
|
||||||
|
cursor: 'pointer', fontSize: 14, padding: '4px 8px', marginLeft: 8,
|
||||||
|
borderRadius: 4, opacity: 0.5, transition: 'opacity 0.15s, color 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = '#ed4245'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.5'; e.currentTarget.style.color = 'var(--header-secondary)'; }}
|
||||||
|
title={`Delete ${m.username}`}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M15 3.999V2H9V3.999H3V5.999H21V3.999H15Z" />
|
||||||
|
<path d="M5 6.99902V18.999C5 20.101 5.897 20.999 7 20.999H17C18.103 20.999 19 20.101 19 18.999V6.99902H5ZM11 17H9V11H11V17ZM15 17H13V11H15V17Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1296,18 +1296,17 @@ const Sidebar = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const generalChannel = channels.find((c) => c.name === "general");
|
// Bundle all server channel keys (not DM keys) so new users get access to everything
|
||||||
const targetChannelId = generalChannel ? generalChannel._id : activeChannel;
|
const serverChannelIds = new Set(channels.map((c) => c._id));
|
||||||
|
const allServerKeys = {};
|
||||||
if (!targetChannelId) {
|
for (const [chId, key] of Object.entries(channelKeys || {})) {
|
||||||
alert("No channel selected.");
|
if (serverChannelIds.has(chId)) {
|
||||||
return;
|
allServerKeys[chId] = key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetKey = channelKeys?.[targetChannelId];
|
if (Object.keys(allServerKeys).length === 0) {
|
||||||
|
alert("Error: You don't have any channel keys to share.");
|
||||||
if (!targetKey) {
|
|
||||||
alert("Error: You don't have the key for this channel yet, so you can't invite others.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1315,7 +1314,7 @@ const Sidebar = ({
|
|||||||
const inviteCode = globalThis.crypto.randomUUID();
|
const inviteCode = globalThis.crypto.randomUUID();
|
||||||
const inviteSecret = randomHex(32);
|
const inviteSecret = randomHex(32);
|
||||||
|
|
||||||
const payload = JSON.stringify({ [targetChannelId]: targetKey });
|
const payload = JSON.stringify(allServerKeys);
|
||||||
const encrypted = await crypto.encryptData(payload, inviteSecret);
|
const encrypted = await crypto.encryptData(payload, inviteSecret);
|
||||||
const blob = JSON.stringify({ c: encrypted.content, t: encrypted.tag, iv: encrypted.iv });
|
const blob = JSON.stringify({ c: encrypted.content, t: encrypted.tag, iv: encrypted.iv });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user