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
5 changes: 4 additions & 1 deletion .claude/commands/review.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ You are orchestrating a multi-agent code review pipeline for a pull request in t

Execute the eight steps below in order. Each step that says "spawn an agent" must use the Agent tool. Agent prompts are written in English (more reliable for model instructions); the final user-facing comment is in **Turkish** (the project's working language).

> **No nested agents.** Only the orchestrator (this top-level loop) spawns agents — exactly at Steps 1, 2, 3, 4, and 5. Every spawned agent is a **leaf**: it completes its task entirely within its own context and MUST NOT call the `Agent`/`Task` tool or spawn any sub-agent of its own. If work needs to be split, only the orchestrator splits it.

---

### Step 1 — Eligibility check (Sonnet)
Expand Down Expand Up @@ -82,7 +84,7 @@ Pass this object to the five parallel reviewers in Step 4.

### Step 4 — Five parallel Sonnet reviewers

> **Read-only mandate for every spawned agent (Steps 1–5).** Each agent's ONLY output is the JSON described in its prompt. An agent MUST NOT post, edit, or delete anything on the PR: never run `gh pr comment`, `gh issue comment`, `gh pr review`, or any `gh api` call with `-X POST`/`-X PATCH`/`-X PUT`/`-X DELETE`. Only the orchestrator posts, and only once, in Step 8. Any agent that writes to the PR is a pipeline violation and causes duplicate comments.
> **Read-only mandate for every spawned agent (Steps 1–5).** Each agent's ONLY output is the JSON described in its prompt. An agent MUST NOT post, edit, or delete anything on the PR: never run `gh pr comment`, `gh issue comment`, `gh pr review`, or any `gh api` call with `-X POST`/`-X PATCH`/`-X PUT`/`-X DELETE`. Only the orchestrator posts, and only once, in Step 8. Any agent that writes to the PR is a pipeline violation and causes duplicate comments. **Each agent is also a leaf node: it does its own work in its own context and MUST NOT call the `Agent`/`Task` tool or spawn any sub-agent.** A spawned agent that spawns further agents is a pipeline violation.

Spawn the following five agents **in parallel** in a single message, all with `subagent_type: "general-purpose"` and `model: "sonnet"`. Each must return strict JSON of the shape:

Expand Down Expand Up @@ -341,6 +343,7 @@ Use `--body-file` (not `--body`) to preserve markdown formatting and avoid shell

- **Never edit files.** This command only reviews.
- **Only the orchestrator posts, and only in Step 8.** Spawned agents (Steps 1–5) are read-only: they return JSON and never call `gh pr comment`, `gh issue comment`, `gh pr review`, or a writing `gh api` (`-X POST/PATCH/PUT/DELETE`). If a sub-agent reports it already posted a comment, do not post again — verify the PR state and reconcile to exactly one correct comment.
- **No nested agents.** Spawned agents are leaf nodes — they complete their task in their own context and never call the `Agent`/`Task` tool or spawn sub-agents. Only the orchestrator spawns, exactly at Steps 1, 2, 3, 4, and 5.
- **Never post more than one comment per run.** All findings are batched into a single comment.
- **Never post without `REVIEW.md`** — abort instead. The whole pipeline relies on it for false-positive control.
- **Always use the full head SHA** in permalinks, captured at Step 1, even if the PR receives new commits during the run. The review applies to the SHA you actually read.
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ The API proxy is pre-configured in `next.config.ts` to forward requests from you
- **RBAC / Permission-aware Admin Panel:** A sidebar-shell admin area (`/admin`) with `user` / `admin` / `superadmin` roles, isolated from the user-facing app. A central `usePermissions()` hook (plus per-permission helpers like `useCanDeleteUsers`) drives **nav, route, and action gating** from the `permissions` on `/users/me`; superadmins pass everything by role and get a searchable **permission-matrix** to **create/delete admin accounts** and assign grants — while the **root superadmin** additionally promotes/demotes superadmins and hands over root via **email OTP**. Permission changes apply **instantly** over a WebSocket — no re-login. See [Admin Panel & RBAC](#-admin-panel--rbac).
- **Support / Ticketing UI + Realtime:** User and admin ticketing screens (open, reply with attachments, status/priority, assignment) with live thread & queue updates over a self-healing WebSocket. See [Support UI](#-support-ticketing-ui).
- **Notification Center:** A header bell (unread badge + dropdown panel) on both the user header and the admin topbar, plus a full paginated inbox (`/notifications`, `/admin/notifications`) with an **All / Unread** filter and mark-(all-)read. Updates **live** over a self-healing WebSocket — a pushed event invalidates the React Query caches so the badge and lists refresh without a reload. Notification copy is rendered from a `type` + `data` payload via i18n (en/tr), and the inbox is reachable on mobile from the drawer / admin sidebar. See [Notification Center](#-notification-center).
- **Active Sessions / Device Management:** A **Security** tab on the profile lists the devices signed in to the account (browser/OS + last active, current device flagged) and revokes any one — or **all other devices** in one click. Admins get the same list plus a **terminate-all** action on the user detail page, gated by the `users:sessions` permission. Revocation is **live**: an `account` WebSocket `sessions_revoked` event re-validates the device, dropping a kicked tab straight to login without a reload. See [Active Sessions](#-active-sessions).

---

Expand Down Expand Up @@ -345,6 +346,17 @@ A persistent, in-app notification surface wired to the backend's `notifications`

---

## 🔒 Active Sessions

A device-management surface over the backend's `user_session` feed, so a user sees where they're signed in and can sign out remotely.

- **Profile security tab** — `SessionsSection` (under the profile **Security** tab) lists active sessions via `useSessions()`, each showing a parsed browser/OS label and last-active time, with the calling device flagged **This device**. A per-row **Revoke** (with confirm dialog) signs out one device; **Sign out all other devices** clears the rest while keeping the current one. The stored IP stays server-side — it's never sent to the client.
- **Admin user detail** — `UserSessionsCard` renders the same list plus a **Terminate all sessions** action on `/admin/users/[id]`, shown only when the admin holds the `users:sessions` permission (`useCanManageUserSessions()`).
- **Live logout** — `useAccountEvents()` (mounted app-wide via `AccountEventsBridge`) holds the self-healing `account` socket; a `sessions_revoked` frame re-validates the device by refetching `/users/me`. If this device was the one revoked the request 401s and the axios layer drops it to login at once; otherwise the sessions list cache is refreshed so the UI reflects the change live.
- **Localized** — all copy lives under the `profile` / `admin` i18n namespaces (en/tr); session toasts resolve from the backend `success.session.*` / `error.session.*` keys.

---

## 🤝 Contributing

Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
Expand Down Expand Up @@ -378,6 +390,7 @@ Distributed under the MIT License. See the `LICENSE` file at the root of the wor
- [ ] Sign in as that permission-limited admin and confirm the nav, routes, and actions gate to their grants
- [ ] Try the support flow: open a ticket at `/support`, then reply/assign it from `/admin/support`
- [ ] Open the notification bell (header / admin topbar) or the full inbox at `/notifications` and watch the badge update live as the backend emits events
- [ ] Open the profile **Security** tab to view active sessions; sign in from a second browser and revoke it (or terminate it from `/admin/users/[id]` with the `users:sessions` grant) and watch that tab drop to login live
- [ ] Check the E2E tests in `tests-e2e/` (incl. `admin/` RBAC + `support/`) to understand the testing patterns
- [ ] Update the i18n translation files in `src/i18n/locales/` as needed

Expand Down
8 changes: 7 additions & 1 deletion src/app/[locale]/(protected)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

import { AccountEventsBridge } from "@/components/common/AccountEventsBridge";
import { ROUTES, getLocalizedPath } from "@/lib/config/routes";

interface ProtectedLayoutProps {
Expand All @@ -24,5 +25,10 @@ export default async function ProtectedLayout({ children, params }: ProtectedLay
redirect(getLocalizedPath(ROUTES.login, locale));
}

return <>{children}</>;
return (
<>
<AccountEventsBridge />
{children}
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,22 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";

import { ActivityTable } from "@/components/admin/ActivityTable";
import { PermissionNote } from "@/components/admin/PermissionNote";
import { StatusBadge, UserStatusBadge } from "@/components/admin/StatusBadge";
import { UserActionDialogs } from "@/components/admin/UserActionDialogs";
import { UserActivityCard } from "@/components/admin/UserActivityCard";
import { UserAvatarCard } from "@/components/admin/UserAvatarCard";
import { UserDangerZone } from "@/components/admin/UserDangerZone";
import { UserEditForm } from "@/components/admin/UserEditForm";
import { UserOverviewCard } from "@/components/admin/UserOverviewCard";
import { UserSessionsCard } from "@/components/admin/UserSessionsCard";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useAdminUser, useAdminUserActivities, useUpdateAdminUser } from "@/hooks/api/use-admin";
import { useAdminUser, useUpdateAdminUser } from "@/hooks/api/use-admin";
import { useUserActions, type UserActionKind } from "@/hooks/api/use-user-actions";
import {
useCanDeleteUsers,
useCanManageUserSessions,
useCanResetUserPassword,
useCanSuspendUsers,
useCanWriteUsers,
Expand All @@ -39,13 +41,11 @@ export function UserDetailContent({ userId }: { userId: string }) {
const currentUserId = useAuthStore((state) => state.user?.id ?? null);

const { data: user, isLoading } = useAdminUser(userId);
const { data: activities, isLoading: activitiesLoading } = useAdminUserActivities(userId, {
limit: 20,
});
const update = useUpdateAdminUser();
const { run, isLoading: isActionLoading } = useUserActions();

const canWrite = useCanWriteUsers();
const canManageSessions = useCanManageUserSessions();
const dangerCaps = {
canResetPassword: useCanResetUserPassword(),
canSuspend: useCanSuspendUsers(),
Expand Down Expand Up @@ -146,6 +146,8 @@ export function UserDetailContent({ userId }: { userId: string }) {
</div>
</div>

{canManageSessions ? <UserSessionsCard userId={user.id} /> : null}

<UserDangerZone
user={user}
isSelf={isSelf}
Expand All @@ -154,20 +156,7 @@ export function UserDetailContent({ userId }: { userId: string }) {
onAction={setAction}
/>

<Card className="border-border/50 bg-card/60">
<CardHeader>
<CardTitle className="text-base">{t("userDetail.activityTitle")}</CardTitle>
<CardDescription>{t("userDetail.activityDescription")}</CardDescription>
</CardHeader>
<CardContent>
<ActivityTable
rows={activities?.data ?? []}
isLoading={activitiesLoading}
showUser={false}
emptyLabel={t("userDetail.activityEmpty")}
/>
</CardContent>
</Card>
<UserActivityCard userId={user.id} />

<UserActionDialogs
action={action}
Expand Down
57 changes: 57 additions & 0 deletions src/components/admin/UserActivityCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"use client";

import { useState } from "react";

import { useTranslation } from "react-i18next";

import { ActivityTable } from "@/components/admin/ActivityTable";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useAdminUserActivities } from "@/hooks/api/use-admin";

const PAGE_SIZE = 20;

interface UserActivityCardProps {
userId: string;
}

export function UserActivityCard({ userId }: UserActivityCardProps) {
const { t } = useTranslation("admin");
// "Show more" grows the window instead of paginating, mirroring the
// sessions card — context stays on screen while older entries append.
const [limit, setLimit] = useState(PAGE_SIZE);
const { data: activities, isLoading } = useAdminUserActivities(userId, { limit });

const rows = activities?.data ?? [];
const total = activities?.total ?? 0;

return (
<Card className="border-border/50 bg-card/60">
<CardHeader>
<CardTitle className="text-base">{t("userDetail.activityTitle")}</CardTitle>
<CardDescription>{t("userDetail.activityDescription")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ActivityTable
rows={rows}
isLoading={isLoading}
showUser={false}
emptyLabel={t("userDetail.activityEmpty")}
/>
{!isLoading && total > rows.length ? (
<div className="flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => {
setLimit((current) => current + PAGE_SIZE);
}}
>
{t("userDetail.activityLoadMore")}
</Button>
</div>
) : null}
</CardContent>
</Card>
);
}
154 changes: 154 additions & 0 deletions src/components/admin/UserSessionsCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client";

import { useState, type ReactNode } from "react";

import { LogOut, MonitorSmartphone, Smartphone } from "lucide-react";
import { useTranslation } from "react-i18next";

import { ConfirmDialog } from "@/components/admin/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAdminUserSessions, useRevokeAdminUserSessions } from "@/hooks/api/use-admin";
import { formatDateTime } from "@/lib/format-date";
import type { SessionItem } from "@/lib/types/session";

const PAGE_SIZE = 10;

const MOBILE_OS = new Set(["iOS", "iPadOS", "Android"]);

interface DeviceIconProps {
os: string | null;
}

const DeviceIcon = ({ os }: DeviceIconProps) => {
const Icon = os && MOBILE_OS.has(os) ? Smartphone : MonitorSmartphone;
return (
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="h-4 w-4" />
</span>
);
};

interface SessionRowProps {
session: SessionItem;
}

const SessionRow = ({ session }: SessionRowProps) => {
const { t } = useTranslation("admin");

const deviceLabel =
session.browser && session.os
? t("userDetail.sessions.deviceLabel", { browser: session.browser, os: session.os })
: (session.browser ?? session.os ?? t("userDetail.sessions.unknownDevice"));

return (
<li className="flex items-center gap-3 rounded-xl border border-border/50 p-3">
<DeviceIcon os={session.os} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{deviceLabel}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("userDetail.sessions.lastActive", { date: formatDateTime(session.last_used_at) })}
</p>
</div>
</li>
);
};

interface UserSessionsCardProps {
userId: string;
}

export function UserSessionsCard({ userId }: UserSessionsCardProps) {
const { t } = useTranslation("admin");
const [limit, setLimit] = useState(PAGE_SIZE);
const { data, isLoading } = useAdminUserSessions(userId, { limit });
const { mutate: revokeAll, isPending } = useRevokeAdminUserSessions();
const [confirmOpen, setConfirmOpen] = useState(false);

const sessions = data?.data ?? [];
const total = data?.total ?? 0;

const handleRevokeAll = () => {
revokeAll(userId, {
onSettled: () => {
setConfirmOpen(false);
},
});
};

let content: ReactNode;
if (isLoading) {
content = <Skeleton className="h-14 w-full rounded-xl" />;
} else if (sessions.length === 0) {
content = (
<p className="py-2 text-center text-sm text-muted-foreground">
{t("userDetail.sessions.empty")}
</p>
);
} else {
content = (
<ul className="space-y-2">
{sessions.map((session) => (
<SessionRow key={session.id} session={session} />
))}
</ul>
);
}

return (
<Card className="border-border/50 bg-card/60">
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
<div>
<CardTitle className="text-base">{t("userDetail.sessions.title")}</CardTitle>
<CardDescription>{t("userDetail.sessions.description")}</CardDescription>
</div>
{sessions.length > 0 ? (
<Button
variant="outline"
size="sm"
className="shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={isPending}
onClick={() => {
setConfirmOpen(true);
}}
>
<LogOut className="h-4 w-4" />
{t("userDetail.sessions.revokeAll")}
</Button>
) : null}
</CardHeader>
<CardContent className="space-y-3">
{content}

{!isLoading && total > sessions.length ? (
<div className="flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => {
setLimit((current) => current + PAGE_SIZE);
}}
>
{t("userDetail.sessions.loadMore")}
</Button>
</div>
) : null}
</CardContent>

<ConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
if (!open) setConfirmOpen(false);
}}
title={t("userDetail.sessions.confirm.title")}
description={t("userDetail.sessions.confirm.description")}
confirmLabel={t("userDetail.sessions.confirm.confirm")}
cancelLabel={t("userDetail.sessions.confirm.cancel")}
onConfirm={handleRevokeAll}
isLoading={isPending}
destructive
/>
</Card>
);
}
16 changes: 16 additions & 0 deletions src/components/common/AccountEventsBridge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"use client";

import { useAccountEvents } from "@/hooks/use-account-events";

/**
* Mounts the per-user account event socket inside server layouts.
*
* The protected layout is a server component and cannot call hooks; rendering
* this empty client component there keeps the socket alive on every protected
* page so live account events (permission changes, remote session revocation)
* reach the user without a refresh.
*/
export function AccountEventsBridge() {
useAccountEvents();
return null;
}
3 changes: 3 additions & 0 deletions src/components/profile/SecurityTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Lock, Shield, CheckCircle2 } from "lucide-react";
import { useTranslation } from "react-i18next";

import { DangerZone } from "@/components/profile/DangerZone";
import { SessionsSection } from "@/components/profile/SessionsSection";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
Expand Down Expand Up @@ -173,6 +174,8 @@ export const SecurityTab = ({ showDangerZone = true }: SecurityTabProps) => {
</CardContent>
</Card>

<SessionsSection />

{showDangerZone ? <DangerZone /> : null}
</div>
);
Expand Down
Loading
Loading