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
7 changes: 7 additions & 0 deletions db/migrations/0014_user_moderation.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ALTER TABLE "user" ADD COLUMN "role" TEXT DEFAULT 'user' NOT NULL;
ALTER TABLE "user" ADD COLUMN "banned" INTEGER DEFAULT 0 NOT NULL;
ALTER TABLE "user" ADD COLUMN "ban_reason" TEXT;
ALTER TABLE "user" ADD COLUMN "ban_expires" INTEGER;
ALTER TABLE "session" ADD COLUMN "impersonated_by" TEXT;

UPDATE "user" SET "role" = 'admin' WHERE "id" IN (SELECT "user_id" FROM "admin");
3 changes: 3 additions & 0 deletions doc/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Run the SQL migrations in filename order against the Turso database:
11. `db/migrations/0011_issue_feedback.sql`
12. `db/migrations/0012_opportunity_workflow.sql`
13. `db/migrations/0013_contribution_readiness.sql`
14. `db/migrations/0014_user_moderation.sql`

The first migration creates Better Auth's user, session, account, and verification tables. The second creates user-owned saved searches. Migration files intentionally contain structure only—never credentials or production data.

Expand Down Expand Up @@ -81,6 +82,8 @@ by a user. The twelfth adds private state, note, follow-up date, and workflow
activity fields to saved opportunities; existing opportunities begin in Saved.
The thirteenth adds the contribution-readiness preference to cloud saved searches;
existing records continue to include every readiness status.
The fourteenth adds role, moderation, and impersonation fields to users and sessions
for the Better Auth admin plugin, and promotes existing administrator records.

## GitHub OAuth

Expand Down
110 changes: 110 additions & 0 deletions scripts/migrate-admin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { createClient } from "@libsql/client";
import * as fs from "fs";

function parseEnv(filePath) {
if (!fs.existsSync(filePath)) return {};
const content = fs.readFileSync(filePath, "utf-8");
const env = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx !== -1) {
const key = trimmed.slice(0, eqIdx).trim();
let val = trimmed.slice(eqIdx + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
env[key] = val;
}
}
return env;
}

const STATEMENTS = [
`ALTER TABLE "user" ADD COLUMN "role" TEXT DEFAULT 'user' NOT NULL;`,
`ALTER TABLE "user" ADD COLUMN "banned" INTEGER DEFAULT 0 NOT NULL;`,
`ALTER TABLE "user" ADD COLUMN "ban_reason" TEXT;`,
`ALTER TABLE "user" ADD COLUMN "ban_expires" INTEGER;`,
`ALTER TABLE "session" ADD COLUMN "impersonated_by" TEXT;`,
`UPDATE "user" SET "role" = 'admin' WHERE "id" IN (SELECT "user_id" FROM "admin");`,
];

async function applyMigration(name, envFile) {
console.log(`\n========================================`);
console.log(`Applying migration 0014 to: ${name} (${envFile})`);
console.log(`========================================`);

const env = parseEnv(envFile);
const url = env.TURSO_DATABASE_URL;
const authToken = env.TURSO_AUTH_TOKEN;

if (!url || !authToken) {
console.error(`[SKIP] Missing credentials in ${envFile}`);
return false;
}

const client = createClient({ url, authToken });

try {
const existingCols = await client.execute("PRAGMA table_info(user)");
const colNames = new Set(existingCols.rows.map((r) => r.name));

for (const stmt of STATEMENTS) {
const trimmed = stmt.trim();
if (!trimmed) continue;

// Skip ALTER TABLE ADD COLUMN if the column already exists
if (trimmed.startsWith('ALTER TABLE "user" ADD COLUMN "role"') && colNames.has("role")) {
console.log(`- Column "role" already exists on "user", skipping.`);
continue;
}
if (trimmed.startsWith('ALTER TABLE "user" ADD COLUMN "banned"') && colNames.has("banned")) {
console.log(`- Column "banned" already exists on "user", skipping.`);
continue;
}
if (trimmed.startsWith('ALTER TABLE "user" ADD COLUMN "ban_reason"') && colNames.has("ban_reason")) {
console.log(`- Column "ban_reason" already exists on "user", skipping.`);
continue;
}
if (trimmed.startsWith('ALTER TABLE "user" ADD COLUMN "ban_expires"') && colNames.has("ban_expires")) {
console.log(`- Column "ban_expires" already exists on "user", skipping.`);
continue;
}

console.log(`Executing: ${trimmed.slice(0, 60)}...`);
await client.execute(trimmed);
console.log(` -> Success`);
}

// Verify
const updatedUserCols = await client.execute("PRAGMA table_info(user)");
console.log(`Updated user columns:`, updatedUserCols.rows.map((r) => r.name).join(", "));

const updatedSessionCols = await client.execute("PRAGMA table_info(session)");
console.log(`Updated session columns:`, updatedSessionCols.rows.map((r) => r.name).join(", "));

const adminUsers = await client.execute("SELECT id, name, email, role, banned FROM user WHERE role = 'admin'");
console.log(`Admin users verified:`, JSON.stringify(adminUsers.rows, null, 2));

return true;
} catch (err) {
console.error(`[ERROR] Failed migrating ${name}:`, err);
return false;
}
}

async function main() {
const previewSuccess = await applyMigration("Preview Database", ".env.preview.local");
const prodSuccess = await applyMigration("Production/Local Database", ".env.local");

if (previewSuccess && prodSuccess) {
console.log("\n[SUCCESS] Migration 0014 applied successfully to both Preview and Production databases.");
process.exit(0);
} else {
console.error("\n[FAILURE] One or more migrations failed.");
process.exit(1);
}
}

main();
2 changes: 1 addition & 1 deletion sonar-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ sonar.sources=src
sonar.tests=tests
sonar.test.inclusions=tests/**/*.test.ts
sonar.javascript.lcov.reportPaths=coverage/lcov.info
sonar.coverage.exclusions=src/components/ui/**,src/app/layout.tsx,src/app/page.tsx,src/components/theme-provider.tsx,src/app/api/auth/**,src/lib/auth-schema.ts,src/lib/auth.ts,src/lib/auth-client.ts,src/features/issues/types/**,src/features/issues/data/**
sonar.coverage.exclusions=src/components/ui/**,src/app/layout.tsx,src/app/page.tsx,src/app/organizations/page.tsx,src/app/pull-requests/page.tsx,src/app/admin/page.tsx,src/components/theme-provider.tsx,src/app/api/auth/**,src/lib/auth-schema.ts,src/lib/auth.ts,src/lib/auth-client.ts,src/features/issues/types/**,src/features/issues/data/**
sonar.sourceEncoding=UTF-8
54 changes: 54 additions & 0 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { AuthControls } from "@/components/auth-controls";
import { DashboardNavigation } from "@/components/dashboard-navigation";
import { ThemeToggle } from "@/components/theme-toggle";
import { AdminDashboard } from "@/features/admin/components/admin-dashboard";
import { getAdminSession } from "@/features/admin/server/admin-guard";

export const metadata: Metadata = {
title: "Admin Console | OpenIssue.dev",
description: "User management and administrative controls for OpenIssue.dev.",
};

export default async function AdminPage() {
const reqHeaders = await headers();
const { session, isAdmin } = await getAdminSession(reqHeaders);

if (!session || !isAdmin) {
redirect("/");
}

return (
<div className="flex min-h-full flex-col bg-background text-foreground">
<header className="border-b bg-background/95 backdrop-blur-xs sticky top-0 z-40">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6">
<div className="flex items-center gap-3">
<DashboardNavigation current="issues" />
</div>
<div className="flex items-center gap-2">
<AuthControls />
<ThemeToggle />
</div>
</div>
</header>

<main className="mx-auto flex-1 w-full max-w-7xl px-4 py-8 sm:px-6">
<Suspense
fallback={
<output className="text-muted-foreground text-sm">
Loading admin dashboard…
</output>
}
>
<AdminDashboard
currentUserId={session.user.id}
currentUserEmail={session.user.email}
/>
</Suspense>
</main>
</div>
);
}
11 changes: 7 additions & 4 deletions src/app/api/cron/weekly-digest/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { eq, or } from "drizzle-orm";
import { and, eq, or } from "drizzle-orm";
import {
deliverWeeklyDigest,
getDigestContext,
Expand Down Expand Up @@ -35,9 +35,12 @@ export async function GET(request: Request) {
eq(repositoryDigestTemplate.userId, user.id),
)
.where(
or(
eq(user.weeklyDigestEnabled, true),
eq(repositoryDigestTemplate.enabled, true),
and(
eq(user.banned, false),
or(
eq(user.weeklyDigestEnabled, true),
eq(repositoryDigestTemplate.enabled, true),
),
),
);
let sent = 0;
Expand Down
19 changes: 18 additions & 1 deletion src/components/auth-controls.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import Image from "next/image";
import { LogOut } from "lucide-react";
import Link from "next/link";
import { LogOut, Shield } from "lucide-react";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";

Expand Down Expand Up @@ -33,8 +34,24 @@ export function AuthControls() {
);
}

const isAdmin = (session.user as { role?: string }).role === "admin";

return (
<div className="flex items-center gap-2">
{isAdmin ? (
<Link href="/admin" passHref>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs font-medium border-primary/30 text-primary hover:bg-primary/10"
title="Admin Console"
aria-label="Admin Console"
>
<Shield className="size-3.5" />
<span className="hidden sm:inline">Admin</span>
</Button>
</Link>
) : null}
{session.user.image ? (
<Image
src={session.user.image}
Expand Down
141 changes: 141 additions & 0 deletions src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"use client"

import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { XIcon } from "lucide-react"

import { cn } from "@/lib/utils"

function Dialog({
...props
}: Readonly<React.ComponentProps<typeof DialogPrimitive.Root>>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}

function DialogTrigger({
...props
}: Readonly<React.ComponentProps<typeof DialogPrimitive.Trigger>>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}

function DialogPortal({
...props
}: Readonly<React.ComponentProps<typeof DialogPrimitive.Portal>>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}

function DialogClose({
...props
}: Readonly<React.ComponentProps<typeof DialogPrimitive.Close>>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}

function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-xs transition-opacity duration-200",
className
)}
{...props}
/>
)
}

function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border bg-background p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}

function DialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-1.5 text-center sm:text-left", className)}
{...props}
/>
)
}

function DialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}

function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
}

function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}

export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
Loading