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
18 changes: 18 additions & 0 deletions .github/workflows/create-release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
on: workflow_dispatch
name: Create a new release

jobs:
create-release:
runs-on: ubuntu-latest
permissions:
contents: write
if: ${{ (github.event.pusher.name != 'github action') && (github.ref == 'refs/heads/main') }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Semantic Release
uses: cycjimmy/semantic-release-action@v6
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
with:
branch: main
3 changes: 3 additions & 0 deletions .releaserc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
plugins: ['@semantic-release/commit-analyzer', '@semantic-release/release-notes-generator', '@semantic-release/github']
}
189 changes: 189 additions & 0 deletions actions/admin/clients/roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"use server";

import { db } from "@/lib/db";
import { oauthClientRoles } from "@/lib/db/schema";
import { requireAdmin } from "@/lib/auth/session";
import {
createClientRoleSchema,
updateClientRoleSchema,
type CreateClientRoleInput,
type UpdateClientRoleInput,
} from "@/lib/validations/admin";
import { and, eq, not } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import type { OAuthClientRole } from "@/lib/db/schema";

export interface ClientRoleResult {
success: boolean;
error?: string;
role?: OAuthClientRole;
}

export interface GetClientRolesResult {
success: boolean;
error?: string;
data?: OAuthClientRole[];
}

export async function getClientRoles(
clientId: string
): Promise<GetClientRolesResult> {
try {
await requireAdmin();

const roles = await db.query.oauthClientRoles.findMany({
where: eq(oauthClientRoles.clientId, clientId),
orderBy: (table, { asc }) => [asc(table.name)],
});

return { success: true, data: roles };
} catch (error) {
console.error("Get client roles error:", error);
return { success: false, error: "Failed to get roles" };
}
}

export async function createClientRole(
input: CreateClientRoleInput
): Promise<ClientRoleResult> {
try {
await requireAdmin();

const parsed = createClientRoleSchema.safeParse(input);
if (!parsed.success) {
return {
success: false,
error: parsed.error.issues[0]?.message || "Invalid input",
};
}

const { clientId, key, name } = parsed.data;
const normalizedKey = key.toLowerCase();

const existing = await db.query.oauthClientRoles.findFirst({
where: and(
eq(oauthClientRoles.clientId, clientId),
eq(oauthClientRoles.key, normalizedKey)
),
});

if (existing) {
return {
success: false,
error: "Role key is already used for this app",
};
}

const now = new Date();
const role: OAuthClientRole = {
id: crypto.randomUUID(),
clientId,
key: normalizedKey,
name,
createdAt: now,
updatedAt: now,
};

await db.insert(oauthClientRoles).values(role);

revalidatePath(`/admin/dashboard/clients/${clientId}`);
revalidatePath("/admin/dashboard/users");

return { success: true, role };
} catch (error) {
console.error("Create client role error:", error);
return { success: false, error: "Failed to create role" };
}
}

export async function updateClientRole(
input: UpdateClientRoleInput
): Promise<ClientRoleResult> {
try {
await requireAdmin();

const parsed = updateClientRoleSchema.safeParse(input);
if (!parsed.success) {
return {
success: false,
error: parsed.error.issues[0]?.message || "Invalid input",
};
}

const { roleId, clientId, key, name } = parsed.data;
const normalizedKey = key.toLowerCase();

const existingRole = await db.query.oauthClientRoles.findFirst({
where: and(
eq(oauthClientRoles.id, roleId),
eq(oauthClientRoles.clientId, clientId)
),
});

if (!existingRole) {
return { success: false, error: "Role not found" };
}

const duplicate = await db.query.oauthClientRoles.findFirst({
where: and(
eq(oauthClientRoles.clientId, clientId),
eq(oauthClientRoles.key, normalizedKey),
not(eq(oauthClientRoles.id, roleId))
),
});

if (duplicate) {
return {
success: false,
error: "Role key is already used for this app",
};
}

await db
.update(oauthClientRoles)
.set({
key: normalizedKey,
name,
updatedAt: new Date(),
})
.where(eq(oauthClientRoles.id, roleId));

const role = await db.query.oauthClientRoles.findFirst({
where: eq(oauthClientRoles.id, roleId),
});

revalidatePath(`/admin/dashboard/clients/${clientId}`);
revalidatePath("/admin/dashboard/users");

return { success: true, role };
} catch (error) {
console.error("Update client role error:", error);
return { success: false, error: "Failed to update role" };
}
}

export async function deleteClientRole(
roleId: string,
clientId: string
): Promise<ClientRoleResult> {
try {
await requireAdmin();

await db
.delete(oauthClientRoles)
.where(
and(
eq(oauthClientRoles.id, roleId),
eq(oauthClientRoles.clientId, clientId)
)
);

revalidatePath(`/admin/dashboard/clients/${clientId}`);
revalidatePath("/admin/dashboard/users");

return { success: true };
} catch (error) {
console.error("Delete client role error:", error);
return { success: false, error: "Failed to delete role" };
}
}
143 changes: 143 additions & 0 deletions actions/admin/users/roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"use server";

import { db } from "@/lib/db";
import {
oauthClientRoles,
oauthClientUserRoles,
users,
} from "@/lib/db/schema";
import { requireAdmin } from "@/lib/auth/session";
import {
setUserRolesSchema,
type SetUserRolesInput,
} from "@/lib/validations/admin";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";

export interface UserRoleAssignment {
clientId: string;
roleIds: string[];
}

export interface GetUserRolesResult {
success: boolean;
error?: string;
data?: UserRoleAssignment[];
}

export interface SetUserRolesResult {
success: boolean;
error?: string;
data?: UserRoleAssignment[];
}

export async function getUserRoleAssignments(
userId: string
): Promise<GetUserRolesResult> {
try {
await requireAdmin();

const rows = await db.query.oauthClientUserRoles.findMany({
where: eq(oauthClientUserRoles.userId, userId),
orderBy: (table, { asc }) => [asc(table.clientId)],
});

const byClient = new Map<string, string[]>();
for (const row of rows) {
const roleIds = byClient.get(row.clientId) ?? [];
roleIds.push(row.roleId);
byClient.set(row.clientId, roleIds);
}

return {
success: true,
data: Array.from(byClient.entries()).map(([clientId, roleIds]) => ({
clientId,
roleIds,
})),
};
} catch (error) {
console.error("Get user role assignments error:", error);
return { success: false, error: "Failed to get user roles" };
}
}

export async function setUserRoleAssignments(
input: SetUserRolesInput
): Promise<SetUserRolesResult> {
try {
await requireAdmin();

const parsed = setUserRolesSchema.safeParse(input);
if (!parsed.success) {
return {
success: false,
error: parsed.error.issues[0]?.message || "Invalid input",
};
}

const { userId, assignments } = parsed.data;

const user = await db.query.users.findFirst({
where: eq(users.id, userId),
});

if (!user) {
return { success: false, error: "User not found" };
}

const normalizedAssignments = assignments.map((assignment) => ({
clientId: assignment.clientId,
roleIds: Array.from(new Set(assignment.roleIds)),
}));
const requestedRoleIds = normalizedAssignments.flatMap(
(assignment) => assignment.roleIds
);

if (requestedRoleIds.length > 0) {
const roles = await db.query.oauthClientRoles.findMany({
where: inArray(oauthClientRoles.id, requestedRoleIds),
});
const rolesById = new Map(roles.map((role) => [role.id, role]));

for (const assignment of normalizedAssignments) {
for (const roleId of assignment.roleIds) {
const role = rolesById.get(roleId);
if (!role || role.clientId !== assignment.clientId) {
return {
success: false,
error: "Role does not belong to the selected app",
};
}
}
}
}

await db.transaction(async (tx) => {
await tx
.delete(oauthClientUserRoles)
.where(eq(oauthClientUserRoles.userId, userId));

const rows = normalizedAssignments.flatMap((assignment) =>
assignment.roleIds.map((roleId) => ({
id: crypto.randomUUID(),
clientId: assignment.clientId,
userId,
roleId,
createdAt: new Date(),
}))
);

if (rows.length > 0) {
await tx.insert(oauthClientUserRoles).values(rows);
}
});

revalidatePath("/admin/dashboard/users");

return { success: true, data: normalizedAssignments };
} catch (error) {
console.error("Set user role assignments error:", error);
return { success: false, error: "Failed to update user roles" };
}
}
7 changes: 7 additions & 0 deletions app/admin/dashboard/clients/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
oauthClients,
oauthClientEmailWhitelist,
oauthClientAppIds,
oauthClientRoles,
} from "@/lib/db/schema";
import { ClientDetailTabs } from "@/components/admin/client-detail-tabs";
import { getOpenIDConfiguration } from "@/lib/oauth/discovery";
Expand Down Expand Up @@ -50,6 +51,11 @@ export default async function EditClientPage({ params }: PageProps) {
orderBy: (table, { desc }) => [desc(table.createdAt)],
});

const roles = await db.query.oauthClientRoles.findMany({
where: eq(oauthClientRoles.clientId, id),
orderBy: (table, { asc }) => [asc(table.name)],
});

const config = getOpenIDConfiguration();
const endpoints = {
issuer: config.issuer,
Expand All @@ -63,6 +69,7 @@ export default async function EditClientPage({ params }: PageProps) {
client={client}
whitelistEmails={whitelistEmails}
appIds={appIds}
roles={roles}
endpoints={endpoints}
/>
);
Expand Down
Loading