Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c134545
Add the admin dashboard
DragonSenseiGuy Jan 14, 2026
bafffb0
fix UI/UX issues
DragonSenseiGuy Jan 14, 2026
01dd04b
Merge branch 'main' into admin
DragonSenseiGuy Jan 14, 2026
c72556c
feat: add isAdmin helper function for permission checks
DragonSenseiGuy Jan 15, 2026
be15212
refactor: use ban object instead of flat ban fields on User
DragonSenseiGuy Jan 15, 2026
3487dd2
refactor: move administrative endpoints to admin.ts router
DragonSenseiGuy Jan 15, 2026
5f8cfec
chore: exclude vitest.config.ts and test files from tsconfig
DragonSenseiGuy Jan 15, 2026
8918671
feat: redirect non-banned users from banned page to /
DragonSenseiGuy Jan 15, 2026
67eb6a0
test: update tests for admin router and ban object changes
DragonSenseiGuy Jan 15, 2026
5b9497c
Use clsx
DragonSenseiGuy Jan 19, 2026
2f7105c
add a new line before return statement
DragonSenseiGuy Jan 19, 2026
f57b3f1
add a new line before return statement
DragonSenseiGuy Jan 19, 2026
215a3f5
refactor: extract user schemas to break circular dependency
DragonSenseiGuy Jan 19, 2026
d7e8a7b
refactor: use BANNED error code with tRPC link for ban handling
DragonSenseiGuy Jan 19, 2026
ab89cb6
style: improve admin dashboard UI
DragonSenseiGuy Jan 19, 2026
08391be
add import
DragonSenseiGuy Jan 19, 2026
697844b
Merge remote-tracking branch 'upstream/main' into admin
ascpixi Jan 28, 2026
048d27a
fix: ensure video source is set before resuming (#125)
Efe-Cal Jan 29, 2026
dd5840e
feat: rest obo api w/ oauth apps (#133)
genr234 Feb 9, 2026
b57610b
fix: restore SelectInput
ascpixi Feb 13, 2026
48a52f4
fix: use updated `adminProcedure` in `admin` API routes
ascpixi Feb 13, 2026
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
2 changes: 1 addition & 1 deletion apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ NEXT_PUBLIC_HACKATIME_URL=https://hackatime.hackclub.com
HACKATIME_REDIRECT_URI=http://localhost:3000/api/auth-hackatime

# Slack bot token used to fetch user profile information (including profile pictures).
# Create a Slack app, enable the "users:read" scope, and generate a bot token.
# Create a Slack app, enable the "users:read" scope, and generate a bot token. The toke will be in the format `xoxb-.....`
SLACK_BOT_TOKEN=

# We use S3 to store all user content (timelapses, thumbnails, etc...) - if you don't have an S3 bucket at hand, you can set up
Expand Down
107 changes: 107 additions & 0 deletions apps/web/etc/scripts/trim-long-handles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// @ts-check
"use strict";

import { parseArgs } from "node:util";

import { confirm } from "@inquirer/prompts";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../../src/generated/prisma/client.js";

const MAX_HANDLE_LENGTH = 16;

async function main() {
const args = parseArgs({
options: {
"database-url": { type: "string" },
"dry-run": { type: "boolean", default: false }
}
});

console.log("");

const databaseUrl = args.values["database-url"];
const dryRun = args.values["dry-run"] ?? false;

if (!databaseUrl) {
console.error("(error) Missing required parameter: --database-url");
return;
}

const adapter = new PrismaPg({ connectionString: databaseUrl });
const prisma = new PrismaClient({ adapter });

try {
console.log("(info) Finding users with handles longer than 16 characters...");

const usersWithLongHandles = await prisma.user.findMany({
where: {
handle: {
not: {
// Prisma doesn't support length filters directly, so we fetch all and filter
}
}
},
select: { id: true, handle: true, displayName: true }
});

const affectedUsers = usersWithLongHandles.filter(user => user.handle.length > MAX_HANDLE_LENGTH);

if (affectedUsers.length === 0) {
console.log("(info) No users found with handles longer than 16 characters. Nothing to do.");
return;
}

console.log(`(info) Found ${affectedUsers.length} user(s) with handles longer than 16 characters:`);
console.log("");

for (const user of affectedUsers) {
const trimmedHandle = user.handle.substring(0, MAX_HANDLE_LENGTH);
console.log(` - [${user.id}] "${user.handle}" (${user.handle.length} chars) -> "${trimmedHandle}"`);
}

console.log("");

if (dryRun) {
console.log("(info) Dry run mode. No changes were made.");
return;
}

if (!await confirm({ message: `Do you wish to trim ${affectedUsers.length} handle(s)? (Y/N)` })) {
console.log("(info) Aborted. No changes were made.");
return;
}

let successCount = 0;
let failureCount = 0;

for (const user of affectedUsers) {
const trimmedHandle = user.handle.substring(0, MAX_HANDLE_LENGTH);

try {
await prisma.user.update({
where: { id: user.id },
data: { handle: trimmedHandle }
});

console.log(`(info) [${user.id}] Handle updated: "${user.handle}" -> "${trimmedHandle}"`);
successCount++;
}
catch (error) {
console.error(`(error) [${user.id}] Failed to update handle:`, error);
failureCount++;
}
}

console.log("");
console.log(`(info) Completed. ${successCount} handle(s) trimmed, ${failureCount} failure(s).`);
}
finally {
await prisma.$disconnect();
}
}

main()
.catch(async (e) => {
console.error(e);
process.exit(1);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isBanned" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "User" ADD COLUMN "bannedAt" TIMESTAMP(3);
ALTER TABLE "User" ADD COLUMN "bannedReason" TEXT NOT NULL DEFAULT '';
ALTER TABLE "User" ADD COLUMN "bannedReasonInternal" TEXT NOT NULL DEFAULT '';
20 changes: 14 additions & 6 deletions apps/web/prisma/promote.mjs → apps/web/prisma/promote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,38 @@ import { parseArgs } from "node:util";
import { confirm } from "@inquirer/prompts";
import { PrismaPg } from "@prisma/adapter-pg";

import { PrismaClient } from "../src/generated/prisma/client.js";
import { PrismaClient } from "../src/generated/prisma/client";

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
async function main() {
const args = parseArgs({
options: {
email: { type: "string" }
}
},
allowPositionals: true
});

console.log("");

if (!args.values.email) {
console.error("(error) No e-mail specified. Aborting.");
const identifier = args.values.email || args.positionals[0];

if (!identifier) {
console.error("(error) No e-mail or handle specified. Usage: node promote.mjs <email-or-handle>");
return;
}

const user = await prisma.user.findFirst({
where: { email: args.values.email }
where: {
OR: [
{ email: identifier },
{ handle: identifier }
]
}
});

if (!user) {
console.error(`(error) No user with e-mail ${args.values.email} exists!`);
console.error(`(error) No user with e-mail or handle "${identifier}" exists!`);
return;
}

Expand Down
32 changes: 32 additions & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,43 @@ model User {
slackId String?
lastHeartbeat DateTime @default(now())

isBanned Boolean @default(false)

timelapses Timelapse[]
devices KnownDevice[]
postedComments Comment[]
uploadTokens UploadToken[]
draftTimelapses DraftTimelapse[]
banRecords BanRecord[] @relation("BanRecordTarget")
performedBanRecords BanRecord[] @relation("BanRecordPerformer")
}

enum BanAction {
BAN
UNBAN
}

/// Represents a single ban or unban action performed on a user.
model BanRecord {
id String @id @default(nanoid(12))
createdAt DateTime @default(now())

/// The action that was performed.
action BanAction

/// The public reason for the action (shown to user).
reason String @default("")

/// The internal reason for the action (only visible to admins).
reasonInternal String @default("")

/// The user that was banned or unbanned.
targetId String
target User @relation("BanRecordTarget", fields: [targetId], references: [id], onDelete: Cascade)

/// The admin who performed the action.
performedById String
performedBy User @relation("BanRecordPerformer", fields: [performedById], references: [id], onDelete: Cascade)
}

/// Represents a timelapse that has not yet been uploaded to the server.
Expand Down
64 changes: 62 additions & 2 deletions apps/web/src/__tests__/comments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,17 @@ describe("comment router", () => {
it("returns NO_PERMISSION when deleting another user's comment", async () => {
const user = testFactory.user({ id: "user-1" });
const other = testFactory.user({ id: "user-2" });
const timelapse = testFactory.timelapse({ id: "timelapse-1", ownerId: "owner-1" });
const commentEntity = testFactory.comment({
id: "comment-1",
authorId: other.id,
timelapseId: timelapse.id,
});

mockDatabase.comment.findUnique.mockResolvedValueOnce(commentEntity);
mockDatabase.comment.findUnique.mockResolvedValueOnce({
...commentEntity,
timelapse,
});

const caller = createCaller(createMockContext(user));
const result = await caller.delete({ commentId: commentEntity.id });
Expand All @@ -199,14 +204,69 @@ describe("comment router", () => {
expect(mockDatabase.comment.delete).not.toHaveBeenCalled();
});

it("allows admin to delete any comment", async () => {
const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" });
const other = testFactory.user({ id: "user-2" });
const timelapse = testFactory.timelapse({ id: "timelapse-1", ownerId: "owner-1" });
const commentEntity = testFactory.comment({
id: "comment-1",
authorId: other.id,
timelapseId: timelapse.id,
});

mockDatabase.comment.findUnique.mockResolvedValueOnce({
...commentEntity,
timelapse,
});
mockDatabase.comment.delete.mockResolvedValueOnce(commentEntity);

const caller = createCaller(createMockContext(admin));
const result = await caller.delete({ commentId: commentEntity.id });

expect(result.ok).toBe(true);
expect(mockDatabase.comment.delete).toHaveBeenCalledWith({
where: { id: commentEntity.id },
});
});

it("allows timelapse owner to delete comments on their timelapse", async () => {
const owner = testFactory.user({ id: "owner-1" });
const commenter = testFactory.user({ id: "user-2" });
const timelapse = testFactory.timelapse({ id: "timelapse-1", ownerId: owner.id });
const commentEntity = testFactory.comment({
id: "comment-1",
authorId: commenter.id,
timelapseId: timelapse.id,
});

mockDatabase.comment.findUnique.mockResolvedValueOnce({
...commentEntity,
timelapse,
});
mockDatabase.comment.delete.mockResolvedValueOnce(commentEntity);

const caller = createCaller(createMockContext(owner));
const result = await caller.delete({ commentId: commentEntity.id });

expect(result.ok).toBe(true);
expect(mockDatabase.comment.delete).toHaveBeenCalledWith({
where: { id: commentEntity.id },
});
});

it("deletes owned comment", async () => {
const user = testFactory.user({ id: "user-1" });
const timelapse = testFactory.timelapse({ id: "timelapse-1", ownerId: "owner-1" });
const commentEntity = testFactory.comment({
id: "comment-1",
authorId: user.id,
timelapseId: timelapse.id,
});

mockDatabase.comment.findUnique.mockResolvedValueOnce(commentEntity);
mockDatabase.comment.findUnique.mockResolvedValueOnce({
...commentEntity,
timelapse,
});
mockDatabase.comment.delete.mockResolvedValueOnce(commentEntity);

const caller = createCaller(createMockContext(user));
Expand Down
21 changes: 20 additions & 1 deletion apps/web/src/__tests__/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import type {
Comment,
UploadToken,
DraftTimelapse,
BanRecord,
} from "@/generated/prisma/client";
import { PermissionLevel, TimelapseVisibility, VideoContainerKind } from "@/generated/prisma/client";
import { PermissionLevel, TimelapseVisibility, VideoContainerKind, BanAction } from "@/generated/prisma/client";

/**
* Generates a Nano ID-like string (12 characters).
Expand Down Expand Up @@ -39,6 +40,10 @@ export const testFactory = {
hackatimeAccessToken: overrides.hackatimeAccessToken ?? null,
lastHeartbeat: faker.date.recent(),
hackatimeRefreshToken: overrides?.hackatimeRefreshToken ?? null,
isBanned: false,
bannedAt: null,
bannedReason: "",
bannedReasonInternal: "",
...overrides,
}),

Expand Down Expand Up @@ -122,4 +127,18 @@ export const testFactory = {
thumbnailTokenId: faker.string.uuid(),
...overrides,
}),

/**
* Creates a mock BanRecord object.
*/
banRecord: (overrides: Partial<BanRecord> = {}): BanRecord => ({
id: nanoid(12),
createdAt: faker.date.recent(),
action: BanAction.BAN,
reason: faker.lorem.sentence(),
reasonInternal: faker.lorem.sentence(),
targetId: nanoid(12),
performedById: nanoid(12),
...overrides,
}),
};
3 changes: 3 additions & 0 deletions apps/web/src/__tests__/mocks/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type MockDatabase = {
comment: MockPrismaModel<unknown>;
uploadToken: MockPrismaModel<unknown>;
draftTimelapse: MockPrismaModel<unknown>;
banRecord: MockPrismaModel<unknown>;
$transaction: Mock<(fn: (tx: MockDatabase) => Promise<unknown>) => Promise<unknown>>;
$connect: Mock<() => Promise<void>>;
$disconnect: Mock<() => Promise<void>>;
Expand All @@ -66,6 +67,7 @@ export function createMockDatabase(): MockDatabase {
comment: createMockModel(),
uploadToken: createMockModel(),
draftTimelapse: createMockModel(),
banRecord: createMockModel(),
$transaction: vi.fn((fn) => fn(mockDatabase)),
$connect: vi.fn(),
$disconnect: vi.fn(),
Expand Down Expand Up @@ -95,6 +97,7 @@ export function resetMockDatabase(): void {
resetModel(mockDatabase.comment);
resetModel(mockDatabase.uploadToken);
resetModel(mockDatabase.draftTimelapse);
resetModel(mockDatabase.banRecord);
mockDatabase.$transaction.mockReset();
mockDatabase.$transaction.mockImplementation((fn) => fn(mockDatabase));
mockDatabase.$connect.mockReset();
Expand Down
Loading