-
Notifications
You must be signed in to change notification settings - Fork 0
AUTH-8 Project server actions #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| "use server"; | ||
|
|
||
| import "server-only"; | ||
|
|
||
| import { prisma } from "@/lib/prisma"; | ||
|
|
||
| type ProjectRow = { | ||
| id: string; | ||
| name: string; | ||
| description: string | null; | ||
| apiKey: string; | ||
| createdAt: Date; | ||
| updatedAt: Date; | ||
| }; | ||
|
|
||
| function generateApiKey(): string { | ||
| return crypto.randomUUID(); | ||
| } | ||
|
|
||
| function maskApiKey(apiKey: string): string { | ||
| if (apiKey.length <= 8) { | ||
| return "•".repeat(apiKey.length); | ||
| } | ||
| return `•••••••••••••••••••••••••••••••••${apiKey.slice(-4)}`; | ||
| } | ||
|
|
||
| function toMasked(project: ProjectRow): ProjectMasked { | ||
| return { | ||
| id: project.id, | ||
| name: project.name, | ||
| description: project.description, | ||
| apiKeyMasked: maskApiKey(project.apiKey), | ||
| createdAt: project.createdAt, | ||
| updatedAt: project.updatedAt, | ||
| }; | ||
| } | ||
|
|
||
| export type ProjectMasked = { | ||
| id: string; | ||
| name: string; | ||
| description: string | null; | ||
| apiKeyMasked: string; | ||
| createdAt: Date; | ||
| updatedAt: Date; | ||
| }; | ||
|
|
||
| export type ProjectWithApiKey = { | ||
| id: string; | ||
| name: string; | ||
| description: string | null; | ||
| apiKey: string; | ||
| createdAt: Date; | ||
| updatedAt: Date; | ||
| }; | ||
|
|
||
| export type UpdateProjectData = { | ||
| name?: string; | ||
| description?: string | null; | ||
| }; | ||
|
|
||
| /** Registers an external app; returns the full API key once. */ | ||
| export async function createProject( | ||
| name: string, | ||
| description: string | null, | ||
| ): Promise<ProjectWithApiKey> { | ||
| const trimmed = name.trim(); | ||
| if (!trimmed) { | ||
| throw new Error("Project name is required"); | ||
| } | ||
|
|
||
| const apiKey = generateApiKey(); | ||
| const project = await prisma.project.create({ | ||
| data: { | ||
| name: trimmed, | ||
| description: description?.trim() || null, | ||
| apiKey, | ||
| }, | ||
| }); | ||
|
|
||
| return { | ||
| id: project.id, | ||
| name: project.name, | ||
| description: project.description, | ||
| apiKey: project.apiKey, | ||
| createdAt: project.createdAt, | ||
| updatedAt: project.updatedAt, | ||
| }; | ||
| } | ||
|
|
||
| /** Single project; API key is masked. */ | ||
| export async function getProject(id: string): Promise<ProjectMasked | null> { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| const project = await prisma.project.findUnique({ where: { id } }); | ||
| if (!project) return null; | ||
| return toMasked(project); | ||
| } | ||
|
|
||
| /** All projects; API keys masked. */ | ||
| export async function getProjects(): Promise<ProjectMasked[]> { | ||
| const projects = await prisma.project.findMany({ | ||
| orderBy: { name: "asc" }, | ||
| }); | ||
| return projects.map(toMasked); | ||
| } | ||
|
|
||
| /** Updates name and/or description only. */ | ||
| export async function updateProject( | ||
| id: string, | ||
| data: UpdateProjectData, | ||
| ): Promise<ProjectMasked | null> { | ||
| const hasName = data.name !== undefined; | ||
| const hasDescription = data.description !== undefined; | ||
| if (!hasName && !hasDescription) { | ||
| throw new Error("No fields to update"); | ||
| } | ||
|
|
||
| const updatePayload: { name?: string; description?: string | null } = {}; | ||
| if (hasName) { | ||
| const trimmed = data.name!.trim(); | ||
| if (!trimmed) { | ||
| throw new Error("Project name cannot be empty"); | ||
| } | ||
| updatePayload.name = trimmed; | ||
| } | ||
| if (hasDescription) { | ||
| updatePayload.description = | ||
| data.description === null || data.description === "" | ||
| ? null | ||
| : data.description!.trim() || null; | ||
| } | ||
|
|
||
| try { | ||
| const project = await prisma.project.update({ | ||
| where: { id }, | ||
| data: updatePayload, | ||
| }); | ||
| return toMasked(project); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Removes related sessions and user–project links. | ||
| */ | ||
| export async function deleteProject(id: string): Promise<boolean> { | ||
| try { | ||
| await prisma.$transaction(async (tx) => { | ||
| await tx.session.deleteMany({ where: { projectId: id } }); | ||
| await tx.userProject.deleteMany({ where: { projectId: id } }); | ||
| await tx.project.delete({ where: { id } }); | ||
| }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** Regenerates the API key; returns the full new key once. */ | ||
| export async function resetProjectAPIKey( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one's worth calling out separately from the general authz note above: even once a caller-identity check is added, this returns the plaintext API key and there's no hashing anywhere for |
||
| id: string, | ||
| ): Promise<ProjectWithApiKey | null> { | ||
| const apiKey = generateApiKey(); | ||
| try { | ||
| const project = await prisma.project.update({ | ||
| where: { id }, | ||
| data: { apiKey }, | ||
| }); | ||
| return { | ||
| id: project.id, | ||
| name: project.name, | ||
| description: project.description, | ||
| apiKey: project.apiKey, | ||
| createdAt: project.createdAt, | ||
| updatedAt: project.updatedAt, | ||
| }; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
None of the six actions in this file check the caller's identity before touching the DB — this one included. If there's a follow-up ticket adding an authorization layer before this gets exported into a client-facing flow, that's a reasonable way to sequence it; just flagging so it's a deliberate choice. Worth confirming before this (or the action layer that wraps it) goes live, since
"use server"exports are reachable as soon as anything imports from this file.