Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ import {
COMMIT_CACHE_CHANNEL,
} from "@/features/github-integration/lib/commit-cache";
import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id";
import { useProjectPermissions } from "@/hooks/use-project-permissions";

export const GitHubIntegrationClient = () => {
const projectId = useProjectId();
const workspaceId = useWorkspaceId();
const { data: repository, isLoading } = useGetRepository(projectId);
const { isProjectAdmin } = useProjectPermissions({ projectId, workspaceId });
const canManageGithub = isProjectAdmin;
const [commitsCount, setCommitsCount] = useState(0);
const documentationPath = workspaceId
? `/workspaces/${workspaceId}/projects/${projectId}/github/documentation`
Expand Down Expand Up @@ -188,7 +191,7 @@ export const GitHubIntegrationClient = () => {
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<ConnectRepository projectId={projectId} />
<ConnectRepository projectId={projectId} canManage={canManageGithub} />
</CardContent>
</Card>
{/* Connection node indicator */}
Expand Down Expand Up @@ -438,7 +441,7 @@ export const GitHubIntegrationClient = () => {

</div>
<div className="pt-6 px-6 border-t ">
<ConnectRepository projectId={projectId} isUpdate />
<ConnectRepository projectId={projectId} isUpdate canManage={canManageGithub} />
</div>
</SheetContent>
</Sheet>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,18 +157,26 @@ export const ProjectMembersClient = () => {
});
};

const memberOptions = workspaceMembers.map((member) => ({
label: member.name || "Unknown",
value: member.userId,
icon: () => (
<Avatar className="h-5 w-5 mr-2">
<AvatarImage src={member.profileImageUrl || undefined} />
<AvatarFallback className="text-[8px]">
{member.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
),
}));
// IDs of members already in this project
const existingProjectMemberUserIds = new Set(projectMembers.map((m) => m.userId));

const memberOptions = workspaceMembers
// Exclude members already in the project
.filter((member) => !existingProjectMemberUserIds.has(member.userId))
// Exclude workspace OWNER (they have implicit access to all projects)
.filter((member) => member.role !== "OWNER")
.map((member) => ({
label: member.name || "Unknown",
value: member.userId,
icon: () => (
<Avatar className="h-5 w-5 mr-2">
<AvatarImage src={member.profileImageUrl || undefined} />
<AvatarFallback className="text-[8px]">
{member.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
),
}));

if (isLoading) {
return (
Expand Down
31 changes: 11 additions & 20 deletions src/features/audit-logs/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,14 +164,10 @@ export async function getActivityLogs({

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

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

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

Expand Down Expand Up @@ -250,23 +246,17 @@ export async function getActivityLogs({
function getUserIdFromDocument(
doc: Record<string, unknown>,
activityType: ActivityType,
action?: string
_action?: string
): string | undefined {
// First, check if document has lastModifiedBy field (for updates)
if (action === "updated" && doc.lastModifiedBy) {
// Check lastModifiedBy first — it's set on both task creates AND updates
if (doc.lastModifiedBy) {
return doc.lastModifiedBy as string;
}

switch (activityType) {
case ActivityType.TASK:
// For task creates, use assigneeId or the first assigneeIds
// For updates, we now check lastModifiedBy first (above)
if (action === "created") {
const assigneeId = doc.assigneeId as string | undefined;
const assigneeIds = doc.assigneeIds as string[] | undefined;
return assigneeId || (assigneeIds?.[0]);
}
// For updates without lastModifiedBy, return undefined
// lastModifiedBy already handled above for both creates and updates.
// Return undefined — we don't want to fall back to the assignee as the "author".
return undefined;

case ActivityType.TIME_LOG:
Expand Down Expand Up @@ -298,10 +288,11 @@ function getUserIdFromDocument(

case ActivityType.PROJECT:
case ActivityType.SPRINT:
// Both have a createdBy field stored at creation time
return (doc.createdBy as string | undefined);

case ActivityType.CUSTOM_COLUMN:
case ActivityType.NOTIFICATION:
// These don't have direct user fields
// We'll need to infer from workspace membership
return undefined;

default:
Expand Down
55 changes: 54 additions & 1 deletion src/features/auth/server/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@ import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { deleteCookie, setCookie } from "hono/cookie";
import { z } from "zod";
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
import { promisify } from "util";

const scryptAsync = promisify(scrypt);
const PASSWORD_HISTORY_LIMIT = 10;

async function hashPasswordForHistory(password: string): Promise<string> {
const salt = randomBytes(16);
const hash = (await scryptAsync(password, salt, 64)) as Buffer;
return `${salt.toString("hex")}:${hash.toString("hex")}`;
}

async function isPasswordInHistory(password: string, history: string[]): Promise<boolean> {
for (const storedHash of history) {
try {
const [saltHex, hashHex] = storedHash.split(":");
if (!saltHex || !hashHex) continue;
const salt = Buffer.from(saltHex, "hex");
const storedHashBuffer = Buffer.from(hashHex, "hex");
const computedHash = (await scryptAsync(password, salt, 64)) as Buffer;
if (timingSafeEqual(storedHashBuffer, computedHash)) return true;
} catch {
continue;
}
}
return false;
}

import {
loginSchema,
Expand Down Expand Up @@ -669,11 +696,37 @@ const app = new Hono()
async (c) => {
try {
const account = c.get("account");
const user = c.get("user");
const { currentPassword, newPassword } = c.req.valid("json");

// Update password using Appwrite's updatePassword method
// Check new password against previously used password history
const passwordHistory: string[] = Array.isArray(user.prefs?.passwordHistory)
? (user.prefs.passwordHistory as string[])
: [];

if (await isPasswordInHistory(newPassword, passwordHistory)) {
return c.json({
error: "You have used this password before. Please choose a password you haven't used previously.",
}, 400);
}

// Update password — Appwrite verifies currentPassword internally
await account.updatePassword(newPassword, currentPassword);

// Hash the retired password and prepend to history (keep last N)
const retiredHash = await hashPasswordForHistory(currentPassword);
const updatedHistory = [retiredHash, ...passwordHistory].slice(0, PASSWORD_HISTORY_LIMIT);

const currentPrefs: Record<string, unknown> = {};
if (user.prefs && typeof user.prefs === "object" && !Array.isArray(user.prefs)) {
Object.entries(user.prefs).forEach(([k, v]) => {
if (v !== undefined && v !== null && typeof v !== "function") {
currentPrefs[k] = v;
}
});
}
await account.updatePrefs({ ...currentPrefs, passwordHistory: updatedHistory });

return c.json({ success: true, message: "Password updated successfully" });
} catch (error: unknown) {

Expand Down
48 changes: 43 additions & 5 deletions src/features/custom-columns/components/enhanced-data-kanban.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import React, { useCallback, useEffect, useState, useMemo } from "react";
import React, { useCallback, useEffect, useState, useMemo, useRef } from "react";
import {
DragDropContext,
Droppable,
Expand Down Expand Up @@ -48,6 +48,7 @@ import { useValidateTransition, TransitionValidationResult } from "@/features/wo
import { useGetCustomColumns } from "../api/use-get-custom-columns";
import { useDefaultColumns } from "../hooks/use-default-columns";
import { CustomColumnHeader } from "./custom-column-header";
import { useKanbanAutoScroll } from "@/hooks/use-kanban-auto-scroll";
import { CustomColumn } from "../types";
import { useUpdateColumnOrder } from "@/features/default-column-settings/api/use-update-column-order";

Expand Down Expand Up @@ -117,6 +118,7 @@ export const EnhancedDataKanban = ({


useCreateTaskModal();
const { scrollRef, handleDragStart, handleDragEnd } = useKanbanAutoScroll();
const { getEnabledColumns } = useDefaultColumns(workspaceId, projectId);
const { mutate: updateColumnOrder } = useUpdateColumnOrder();

Expand Down Expand Up @@ -169,6 +171,9 @@ export const EnhancedDataKanban = ({

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

const [selectedTasks, setSelectedTasks] = useState<Set<string>>(new Set());
const [selectionMode, setSelectionMode] = useState(false);
Expand Down Expand Up @@ -215,9 +220,33 @@ export const EnhancedDataKanban = ({
});
};

// Update ordered columns when allColumns changes
// Sync orderedColumns from allColumns ONLY when the set of column IDs changes
// (initial load, or columns added/removed). Ignores position-only changes so
// that a manual drag-reorder is never snapped back by a subsequent re-render.
useEffect(() => {
setOrderedColumns(allColumns);
const current = orderedColumnsRef.current;
const currentIds = new Set(current.map((c) => c.id));
const newIds = new Set(allColumns.map((c) => c.id));

const hasAdded = allColumns.some((c) => !currentIds.has(c.id));
const hasRemoved = current.some((c) => !newIds.has(c.id));

// Initial load — set directly
if (current.length === 0) {
setOrderedColumns(allColumns);
return;
}

// No structural change — preserve the user's manual order
if (!hasAdded && !hasRemoved) return;

// Columns were added or removed — merge while preserving current order
const allColumnsMap = new Map(allColumns.map((c) => [c.id, c]));
const merged = current
.filter((c) => newIds.has(c.id))
.map((c) => allColumnsMap.get(c.id)!)
.concat(allColumns.filter((c) => !currentIds.has(c.id)));
setOrderedColumns(merged);
}, [allColumns]);

// Update tasks when data changes or columns change
Expand Down Expand Up @@ -601,12 +630,21 @@ export const EnhancedDataKanban = ({
</div>
</div>

<DragDropContext onDragEnd={onDragEnd}>
<DragDropContext
onDragEnd={(result) => {
handleDragEnd();
onDragEnd(result);
}}
onDragStart={handleDragStart}
>
<Droppable droppableId="columns" direction="horizontal" type="column">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
ref={(el: HTMLDivElement | null) => {
scrollRef.current = el;
provided.innerRef(el);
}}
className="flex overflow-x-scroll gap-4 pb-4 kanban-scrollbar"
>
{orderedColumns.map((column, index) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ type FormValues = z.infer<typeof formSchema>;
interface ConnectRepositoryProps {
projectId: string;
isUpdate?: boolean;
canManage?: boolean;
}

export const ConnectRepository = ({
projectId,
isUpdate = false,
canManage = false,
}: ConnectRepositoryProps) => {
const [open, setOpen] = useState(false);
const [isCheckingRepo, setIsCheckingRepo] = useState(false);
Expand Down Expand Up @@ -174,6 +176,7 @@ export const ConnectRepository = ({
};

if (isUpdate && repository) {
if (!canManage) return null;
return (
<>
<ConfirmDialog />
Expand Down Expand Up @@ -330,6 +333,8 @@ export const ConnectRepository = ({
);
}

if (!canManage) return null;

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
Expand Down
41 changes: 34 additions & 7 deletions src/features/github-integration/server/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@ const app = new Hono()
return c.json({ error: "Unauthorized" }, 401);
}

// Check if repo is already linked (determines connect vs. update)
const existing = await databases.listDocuments<GitHubRepository>(
DATABASE_ID,
GITHUB_REPOS_ID,
[Query.equal("projectId", projectId)]
);

// RBAC: Only project admins/owners can create new repository connections.
// All project members can update/refetch an existing connection.
if (existing.total === 0) {
const { resolveUserProjectAccess } = await import(
"@/lib/permissions/resolveUserProjectAccess"
);
const access = await resolveUserProjectAccess(databases, user.$id, projectId);
if (!access.isAdmin) {
return c.json(
{ error: "Only project admins and owners can connect repositories" },
403
);
}
}

// Parse GitHub URL
const { owner, repo } = githubAPI.parseGitHubUrl(githubUrl);

Expand All @@ -67,13 +89,6 @@ const app = new Hono()
);
}

// Check if repo is already linked
const existing = await databases.listDocuments<GitHubRepository>(
DATABASE_ID,
GITHUB_REPOS_ID,
[Query.equal("projectId", projectId)]
);

let repository: GitHubRepository;

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

// RBAC: Only project admins/owners can disconnect repositories
const { resolveUserProjectAccess } = await import(
"@/lib/permissions/resolveUserProjectAccess"
);
const access = await resolveUserProjectAccess(databases, user.$id, repository.projectId);
if (!access.isAdmin) {
return c.json(
{ error: "Only project admins and owners can disconnect repositories" },
403
);
}

// Delete repository connection
await databases.deleteDocument(
DATABASE_ID,
Expand Down
Loading
Loading