Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ pnpm dev:init
# Start the development server
pnpm turbo run dev
```
To start and stop the development environment, use `pnpm dev:start-env` and `pnpm dev:stop-env` respectively. To completely tear down the development environment (including removing all Docker volumes), use `pnpm dev:down-env`.
To start and stop the development environment, use `pnpm dev:start-env` and `pnpm dev:stop-env` respectively.

## 🛠️ Deployment
Lapse is meant to be deployed via Docker. In order to deploy the main frontend/backend microservice, use `Dockerfile.web`, located in the root of this repo.
Expand Down
5 changes: 4 additions & 1 deletion apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
# See the README to get an example command on how to get a PostgreSQL database running locally!
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/lapse?schema=public

# The port the web server will run on.
PORT=3000

# These are used for Hackatime OAuth authentication. These both are exposed to the public - but if you want to
# use your own client, and you aren't a Hackatime super-admin, you'll probably need to host it locally...
NEXT_PUBLIC_HACKATIME_CLIENT_ID=s1Eken7aZWPdh2LuzyoEgiuGQs_3OOLdQuyhRDFNPtA
Expand All @@ -15,7 +18,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);
});
5 changes: 5 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:oauth": "vitest src/__tests__/oauthFlow.test.ts",
"dev:create-client": "tsx scripts/create-service-client.ts",
"dev:obo-flow": "tsx scripts/obo-flow.ts",
"preinstall": "npx only-allow pnpm",
"postinstall": "prisma generate",
"db:migrate": "prisma migrate deploy",
Expand Down Expand Up @@ -52,6 +55,7 @@
"pretty-bytes": "^7.1.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"trpc-to-openapi": "^3.1.0",
"webpack": "^5.104.1",
"zod": "^4.2.1"
},
Expand Down Expand Up @@ -92,6 +96,7 @@
"js-yaml": "^4.1.1",
"jsdom": "^27.4.0",
"msw": "^2.12.7",
"node-mocks-http": "^1.17.2",
"ora": "^9.1.0",
"prisma": "^7.2.0",
"supertest": "^7.1.4",
Expand Down
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 '';
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
-- CreateTable
CREATE TABLE "ServiceClient" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"clientSecretHash" TEXT NOT NULL,
"scopes" TEXT[] DEFAULT ARRAY[]::TEXT[],
"redirectUris" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),

CONSTRAINT "ServiceClient_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "ServiceTokenAudit" (
"id" TEXT NOT NULL,
"serviceClientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"ip" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "ServiceTokenAudit_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "ServiceGrant" (
"id" TEXT NOT NULL,
"serviceClientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"scopes" TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"lastUsedAt" TIMESTAMP(3),

CONSTRAINT "ServiceGrant_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "ServiceClient_clientId_key" ON "ServiceClient"("clientId");

-- CreateIndex
CREATE UNIQUE INDEX "ServiceGrant_serviceClientId_userId_key" ON "ServiceGrant"("serviceClientId", "userId");

-- AddForeignKey
ALTER TABLE "ServiceTokenAudit" ADD CONSTRAINT "ServiceTokenAudit_serviceClientId_fkey" FOREIGN KEY ("serviceClientId") REFERENCES "ServiceClient"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ServiceTokenAudit" ADD CONSTRAINT "ServiceTokenAudit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ServiceGrant" ADD CONSTRAINT "ServiceGrant_serviceClientId_fkey" FOREIGN KEY ("serviceClientId") REFERENCES "ServiceClient"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ServiceGrant" ADD CONSTRAINT "ServiceGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
Warnings:

- Added the required column `createdByUserId` to the `ServiceClient` table without a default value. This is not possible if the table is not empty.

*/
-- CreateEnum
CREATE TYPE "ServiceClientTrustLevel" AS ENUM ('UNTRUSTED', 'TRUSTED');

-- AlterTable
ALTER TABLE "ServiceClient" ADD COLUMN "createdByUserId" TEXT NOT NULL,
ADD COLUMN "description" TEXT NOT NULL DEFAULT '',
ADD COLUMN "homepageUrl" TEXT NOT NULL DEFAULT '',
ADD COLUMN "iconUrl" TEXT NOT NULL DEFAULT '',
ADD COLUMN "trustLevel" "ServiceClientTrustLevel" NOT NULL DEFAULT 'UNTRUSTED';

-- CreateTable
CREATE TABLE "ServiceClientReview" (
"id" TEXT NOT NULL,
"serviceClientId" TEXT NOT NULL,
"reviewedByUserId" TEXT NOT NULL,
"status" "ServiceClientTrustLevel" NOT NULL,
"notes" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "ServiceClientReview_pkey" PRIMARY KEY ("id")
);

-- AddForeignKey
ALTER TABLE "ServiceClient" ADD CONSTRAINT "ServiceClient_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ServiceClientReview" ADD CONSTRAINT "ServiceClientReview_serviceClientId_fkey" FOREIGN KEY ("serviceClientId") REFERENCES "ServiceClient"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ServiceClientReview" ADD CONSTRAINT "ServiceClientReview_reviewedByUserId_fkey" FOREIGN KEY ("reviewedByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
18 changes: 13 additions & 5 deletions apps/web/prisma/promote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,30 @@ 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
Loading