Skip to content

Commit 026e989

Browse files
authored
Merge pull request #261 from stemlen/dev
Enhance UI, GitHub integration, and improve project permissions
2 parents fb9fdec + 6b5e530 commit 026e989

14 files changed

Lines changed: 292 additions & 59 deletions

File tree

src/app/(dashboard)/workspaces/[workspaceId]/projects/[projectId]/github/client.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,14 @@ import {
3333
COMMIT_CACHE_CHANNEL,
3434
} from "@/features/github-integration/lib/commit-cache";
3535
import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id";
36+
import { useProjectPermissions } from "@/hooks/use-project-permissions";
3637

3738
export const GitHubIntegrationClient = () => {
3839
const projectId = useProjectId();
3940
const workspaceId = useWorkspaceId();
4041
const { data: repository, isLoading } = useGetRepository(projectId);
42+
const { isProjectAdmin } = useProjectPermissions({ projectId, workspaceId });
43+
const canManageGithub = isProjectAdmin;
4144
const [commitsCount, setCommitsCount] = useState(0);
4245
const documentationPath = workspaceId
4346
? `/workspaces/${workspaceId}/projects/${projectId}/github/documentation`
@@ -188,7 +191,7 @@ export const GitHubIntegrationClient = () => {
188191
</CardDescription>
189192
</CardHeader>
190193
<CardContent className="pt-0">
191-
<ConnectRepository projectId={projectId} />
194+
<ConnectRepository projectId={projectId} canManage={canManageGithub} />
192195
</CardContent>
193196
</Card>
194197
{/* Connection node indicator */}
@@ -438,7 +441,7 @@ export const GitHubIntegrationClient = () => {
438441

439442
</div>
440443
<div className="pt-6 px-6 border-t ">
441-
<ConnectRepository projectId={projectId} isUpdate />
444+
<ConnectRepository projectId={projectId} isUpdate canManage={canManageGithub} />
442445
</div>
443446
</SheetContent>
444447
</Sheet>

src/app/(dashboard)/workspaces/[workspaceId]/projects/[projectId]/members/client.tsx

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -157,18 +157,26 @@ export const ProjectMembersClient = () => {
157157
});
158158
};
159159

160-
const memberOptions = workspaceMembers.map((member) => ({
161-
label: member.name || "Unknown",
162-
value: member.userId,
163-
icon: () => (
164-
<Avatar className="h-5 w-5 mr-2">
165-
<AvatarImage src={member.profileImageUrl || undefined} />
166-
<AvatarFallback className="text-[8px]">
167-
{member.name?.charAt(0).toUpperCase()}
168-
</AvatarFallback>
169-
</Avatar>
170-
),
171-
}));
160+
// IDs of members already in this project
161+
const existingProjectMemberUserIds = new Set(projectMembers.map((m) => m.userId));
162+
163+
const memberOptions = workspaceMembers
164+
// Exclude members already in the project
165+
.filter((member) => !existingProjectMemberUserIds.has(member.userId))
166+
// Exclude workspace OWNER (they have implicit access to all projects)
167+
.filter((member) => member.role !== "OWNER")
168+
.map((member) => ({
169+
label: member.name || "Unknown",
170+
value: member.userId,
171+
icon: () => (
172+
<Avatar className="h-5 w-5 mr-2">
173+
<AvatarImage src={member.profileImageUrl || undefined} />
174+
<AvatarFallback className="text-[8px]">
175+
{member.name?.charAt(0).toUpperCase()}
176+
</AvatarFallback>
177+
</Avatar>
178+
),
179+
}));
172180

173181
if (isLoading) {
174182
return (

src/features/audit-logs/utils.ts

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -164,14 +164,10 @@ export async function getActivityLogs({
164164

165165
// Now create activity logs with resolved user info
166166
for (const { doc, activityType, activityAction } of tempActivities) {
167-
let userId = getUserIdFromDocument(doc, activityType, activityAction);
167+
const userId = getUserIdFromDocument(doc, activityType, activityAction);
168168

169-
// Don't use currentUserId as fallback - we want to show "Unknown User"
170-
// when we genuinely don't know who performed the action
171-
// Only fallback to workspace members for created items where we have no user field
172-
if (!userId && activityAction === "created" && membersResult.documents.length > 0) {
173-
userId = membersResult.documents[0].userId as string;
174-
}
169+
// No fallback to workspace members — that would attribute actions to the wrong person.
170+
// If we can't determine the user, show "Unknown User".
175171

176172
const userInfo = userId ? userMap.get(userId) : null;
177173

@@ -250,23 +246,17 @@ export async function getActivityLogs({
250246
function getUserIdFromDocument(
251247
doc: Record<string, unknown>,
252248
activityType: ActivityType,
253-
action?: string
249+
_action?: string
254250
): string | undefined {
255-
// First, check if document has lastModifiedBy field (for updates)
256-
if (action === "updated" && doc.lastModifiedBy) {
251+
// Check lastModifiedBy first — it's set on both task creates AND updates
252+
if (doc.lastModifiedBy) {
257253
return doc.lastModifiedBy as string;
258254
}
259255

260256
switch (activityType) {
261257
case ActivityType.TASK:
262-
// For task creates, use assigneeId or the first assigneeIds
263-
// For updates, we now check lastModifiedBy first (above)
264-
if (action === "created") {
265-
const assigneeId = doc.assigneeId as string | undefined;
266-
const assigneeIds = doc.assigneeIds as string[] | undefined;
267-
return assigneeId || (assigneeIds?.[0]);
268-
}
269-
// For updates without lastModifiedBy, return undefined
258+
// lastModifiedBy already handled above for both creates and updates.
259+
// Return undefined — we don't want to fall back to the assignee as the "author".
270260
return undefined;
271261

272262
case ActivityType.TIME_LOG:
@@ -298,10 +288,11 @@ function getUserIdFromDocument(
298288

299289
case ActivityType.PROJECT:
300290
case ActivityType.SPRINT:
291+
// Both have a createdBy field stored at creation time
292+
return (doc.createdBy as string | undefined);
293+
301294
case ActivityType.CUSTOM_COLUMN:
302295
case ActivityType.NOTIFICATION:
303-
// These don't have direct user fields
304-
// We'll need to infer from workspace membership
305296
return undefined;
306297

307298
default:

src/features/auth/server/route.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,33 @@ import { Hono } from "hono";
22
import { zValidator } from "@hono/zod-validator";
33
import { deleteCookie, setCookie } from "hono/cookie";
44
import { z } from "zod";
5+
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
6+
import { promisify } from "util";
7+
8+
const scryptAsync = promisify(scrypt);
9+
const PASSWORD_HISTORY_LIMIT = 10;
10+
11+
async function hashPasswordForHistory(password: string): Promise<string> {
12+
const salt = randomBytes(16);
13+
const hash = (await scryptAsync(password, salt, 64)) as Buffer;
14+
return `${salt.toString("hex")}:${hash.toString("hex")}`;
15+
}
16+
17+
async function isPasswordInHistory(password: string, history: string[]): Promise<boolean> {
18+
for (const storedHash of history) {
19+
try {
20+
const [saltHex, hashHex] = storedHash.split(":");
21+
if (!saltHex || !hashHex) continue;
22+
const salt = Buffer.from(saltHex, "hex");
23+
const storedHashBuffer = Buffer.from(hashHex, "hex");
24+
const computedHash = (await scryptAsync(password, salt, 64)) as Buffer;
25+
if (timingSafeEqual(storedHashBuffer, computedHash)) return true;
26+
} catch {
27+
continue;
28+
}
29+
}
30+
return false;
31+
}
532

633
import {
734
loginSchema,
@@ -669,11 +696,37 @@ const app = new Hono()
669696
async (c) => {
670697
try {
671698
const account = c.get("account");
699+
const user = c.get("user");
672700
const { currentPassword, newPassword } = c.req.valid("json");
673701

674-
// Update password using Appwrite's updatePassword method
702+
// Check new password against previously used password history
703+
const passwordHistory: string[] = Array.isArray(user.prefs?.passwordHistory)
704+
? (user.prefs.passwordHistory as string[])
705+
: [];
706+
707+
if (await isPasswordInHistory(newPassword, passwordHistory)) {
708+
return c.json({
709+
error: "You have used this password before. Please choose a password you haven't used previously.",
710+
}, 400);
711+
}
712+
713+
// Update password — Appwrite verifies currentPassword internally
675714
await account.updatePassword(newPassword, currentPassword);
676715

716+
// Hash the retired password and prepend to history (keep last N)
717+
const retiredHash = await hashPasswordForHistory(currentPassword);
718+
const updatedHistory = [retiredHash, ...passwordHistory].slice(0, PASSWORD_HISTORY_LIMIT);
719+
720+
const currentPrefs: Record<string, unknown> = {};
721+
if (user.prefs && typeof user.prefs === "object" && !Array.isArray(user.prefs)) {
722+
Object.entries(user.prefs).forEach(([k, v]) => {
723+
if (v !== undefined && v !== null && typeof v !== "function") {
724+
currentPrefs[k] = v;
725+
}
726+
});
727+
}
728+
await account.updatePrefs({ ...currentPrefs, passwordHistory: updatedHistory });
729+
677730
return c.json({ success: true, message: "Password updated successfully" });
678731
} catch (error: unknown) {
679732

src/features/custom-columns/components/enhanced-data-kanban.tsx

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import React, { useCallback, useEffect, useState, useMemo } from "react";
3+
import React, { useCallback, useEffect, useState, useMemo, useRef } from "react";
44
import {
55
DragDropContext,
66
Droppable,
@@ -48,6 +48,7 @@ import { useValidateTransition, TransitionValidationResult } from "@/features/wo
4848
import { useGetCustomColumns } from "../api/use-get-custom-columns";
4949
import { useDefaultColumns } from "../hooks/use-default-columns";
5050
import { CustomColumnHeader } from "./custom-column-header";
51+
import { useKanbanAutoScroll } from "@/hooks/use-kanban-auto-scroll";
5152
import { CustomColumn } from "../types";
5253
import { useUpdateColumnOrder } from "@/features/default-column-settings/api/use-update-column-order";
5354

@@ -117,6 +118,7 @@ export const EnhancedDataKanban = ({
117118

118119

119120
useCreateTaskModal();
121+
const { scrollRef, handleDragStart, handleDragEnd } = useKanbanAutoScroll();
120122
const { getEnabledColumns } = useDefaultColumns(workspaceId, projectId);
121123
const { mutate: updateColumnOrder } = useUpdateColumnOrder();
122124

@@ -169,6 +171,9 @@ export const EnhancedDataKanban = ({
169171

170172
const [tasks, setTasks] = useState<TasksState>({});
171173
const [orderedColumns, setOrderedColumns] = useState<ColumnData[]>([]);
174+
// Track current orderedColumns in a ref so the sync effect never goes stale
175+
const orderedColumnsRef = useRef(orderedColumns);
176+
orderedColumnsRef.current = orderedColumns;
172177

173178
const [selectedTasks, setSelectedTasks] = useState<Set<string>>(new Set());
174179
const [selectionMode, setSelectionMode] = useState(false);
@@ -215,9 +220,33 @@ export const EnhancedDataKanban = ({
215220
});
216221
};
217222

218-
// Update ordered columns when allColumns changes
223+
// Sync orderedColumns from allColumns ONLY when the set of column IDs changes
224+
// (initial load, or columns added/removed). Ignores position-only changes so
225+
// that a manual drag-reorder is never snapped back by a subsequent re-render.
219226
useEffect(() => {
220-
setOrderedColumns(allColumns);
227+
const current = orderedColumnsRef.current;
228+
const currentIds = new Set(current.map((c) => c.id));
229+
const newIds = new Set(allColumns.map((c) => c.id));
230+
231+
const hasAdded = allColumns.some((c) => !currentIds.has(c.id));
232+
const hasRemoved = current.some((c) => !newIds.has(c.id));
233+
234+
// Initial load — set directly
235+
if (current.length === 0) {
236+
setOrderedColumns(allColumns);
237+
return;
238+
}
239+
240+
// No structural change — preserve the user's manual order
241+
if (!hasAdded && !hasRemoved) return;
242+
243+
// Columns were added or removed — merge while preserving current order
244+
const allColumnsMap = new Map(allColumns.map((c) => [c.id, c]));
245+
const merged = current
246+
.filter((c) => newIds.has(c.id))
247+
.map((c) => allColumnsMap.get(c.id)!)
248+
.concat(allColumns.filter((c) => !currentIds.has(c.id)));
249+
setOrderedColumns(merged);
221250
}, [allColumns]);
222251

223252
// Update tasks when data changes or columns change
@@ -601,12 +630,21 @@ export const EnhancedDataKanban = ({
601630
</div>
602631
</div>
603632

604-
<DragDropContext onDragEnd={onDragEnd}>
633+
<DragDropContext
634+
onDragEnd={(result) => {
635+
handleDragEnd();
636+
onDragEnd(result);
637+
}}
638+
onDragStart={handleDragStart}
639+
>
605640
<Droppable droppableId="columns" direction="horizontal" type="column">
606641
{(provided) => (
607642
<div
608643
{...provided.droppableProps}
609-
ref={provided.innerRef}
644+
ref={(el: HTMLDivElement | null) => {
645+
scrollRef.current = el;
646+
provided.innerRef(el);
647+
}}
610648
className="flex overflow-x-scroll gap-4 pb-4 kanban-scrollbar"
611649
>
612650
{orderedColumns.map((column, index) => {

src/features/github-integration/components/connect-repository.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,13 @@ type FormValues = z.infer<typeof formSchema>;
5050
interface ConnectRepositoryProps {
5151
projectId: string;
5252
isUpdate?: boolean;
53+
canManage?: boolean;
5354
}
5455

5556
export const ConnectRepository = ({
5657
projectId,
5758
isUpdate = false,
59+
canManage = false,
5860
}: ConnectRepositoryProps) => {
5961
const [open, setOpen] = useState(false);
6062
const [isCheckingRepo, setIsCheckingRepo] = useState(false);
@@ -174,6 +176,7 @@ export const ConnectRepository = ({
174176
};
175177

176178
if (isUpdate && repository) {
179+
if (!canManage) return null;
177180
return (
178181
<>
179182
<ConfirmDialog />
@@ -330,6 +333,8 @@ export const ConnectRepository = ({
330333
);
331334
}
332335

336+
if (!canManage) return null;
337+
333338
return (
334339
<Dialog open={open} onOpenChange={setOpen}>
335340
<DialogTrigger asChild>

src/features/github-integration/server/route.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,28 @@ const app = new Hono()
4949
return c.json({ error: "Unauthorized" }, 401);
5050
}
5151

52+
// Check if repo is already linked (determines connect vs. update)
53+
const existing = await databases.listDocuments<GitHubRepository>(
54+
DATABASE_ID,
55+
GITHUB_REPOS_ID,
56+
[Query.equal("projectId", projectId)]
57+
);
58+
59+
// RBAC: Only project admins/owners can create new repository connections.
60+
// All project members can update/refetch an existing connection.
61+
if (existing.total === 0) {
62+
const { resolveUserProjectAccess } = await import(
63+
"@/lib/permissions/resolveUserProjectAccess"
64+
);
65+
const access = await resolveUserProjectAccess(databases, user.$id, projectId);
66+
if (!access.isAdmin) {
67+
return c.json(
68+
{ error: "Only project admins and owners can connect repositories" },
69+
403
70+
);
71+
}
72+
}
73+
5274
// Parse GitHub URL
5375
const { owner, repo } = githubAPI.parseGitHubUrl(githubUrl);
5476

@@ -67,13 +89,6 @@ const app = new Hono()
6789
);
6890
}
6991

70-
// Check if repo is already linked
71-
const existing = await databases.listDocuments<GitHubRepository>(
72-
DATABASE_ID,
73-
GITHUB_REPOS_ID,
74-
[Query.equal("projectId", projectId)]
75-
);
76-
7792
let repository: GitHubRepository;
7893

7994
if (existing.total > 0) {
@@ -259,6 +274,18 @@ const app = new Hono()
259274
return c.json({ error: "Unauthorized" }, 401);
260275
}
261276

277+
// RBAC: Only project admins/owners can disconnect repositories
278+
const { resolveUserProjectAccess } = await import(
279+
"@/lib/permissions/resolveUserProjectAccess"
280+
);
281+
const access = await resolveUserProjectAccess(databases, user.$id, repository.projectId);
282+
if (!access.isAdmin) {
283+
return c.json(
284+
{ error: "Only project admins and owners can disconnect repositories" },
285+
403
286+
);
287+
}
288+
262289
// Delete repository connection
263290
await databases.deleteDocument(
264291
DATABASE_ID,

0 commit comments

Comments
 (0)