feat: implement admin authentication context and update related procedures and tokens
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
import type { GatewaySessionInfo } from './auth/sessionService.js';
|
||||||
|
import type { UserRecord } from './auth/userRepository.js';
|
||||||
|
|
||||||
|
export interface AdminAuthContext {
|
||||||
|
session: GatewaySessionInfo;
|
||||||
|
user: UserRecord;
|
||||||
|
isSuperuser: boolean;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { procedure, router } from './trpc.js';
|
import { procedure, router } from './trpc.js';
|
||||||
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
||||||
|
import type { AdminAuthContext } from './adminAuth.js';
|
||||||
|
import type { GatewayApiContext } from './context.js';
|
||||||
import {
|
import {
|
||||||
GATEWAY_BUILD_STATUSES,
|
GATEWAY_BUILD_STATUSES,
|
||||||
GATEWAY_PROFILE_STATUSES,
|
GATEWAY_PROFILE_STATUSES,
|
||||||
@@ -21,28 +23,159 @@ const zServerAction = z.enum([
|
|||||||
'DELAY',
|
'DELAY',
|
||||||
'RESET_NOW',
|
'RESET_NOW',
|
||||||
'RESET_SCHEDULED',
|
'RESET_SCHEDULED',
|
||||||
|
'OPEN_SURVEY',
|
||||||
'SHUTDOWN',
|
'SHUTDOWN',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const adminProcedure = procedure.use(({ ctx, next }) => {
|
const ADMIN_ROLE_PREFIX = 'admin.';
|
||||||
if (!ctx.adminToken) {
|
const ADMIN_ROLE_SUPERUSER = 'admin.superuser';
|
||||||
throw new TRPCError({
|
const ROLE_SUPERUSER = 'superuser';
|
||||||
code: 'FORBIDDEN',
|
const ROLE_ADMIN_USERS = 'admin.users.manage';
|
||||||
message: 'Admin token is not configured.',
|
const ROLE_ADMIN_PROFILES = 'admin.profiles.manage';
|
||||||
});
|
const ROLE_ADMIN_NOTICE = 'admin.notice.manage';
|
||||||
}
|
const ROLE_RESET_SCHEDULE = 'admin.reset.schedule';
|
||||||
|
const ROLE_RESUME_WHEN_STOPPED = 'admin.resume.when-stopped';
|
||||||
|
const ROLE_SURVEY_OPEN = 'admin.survey.open';
|
||||||
|
|
||||||
|
const readSessionToken = (
|
||||||
|
headers: Record<string, string | string[] | undefined>
|
||||||
|
): string | null => {
|
||||||
const provided =
|
const provided =
|
||||||
ctx.requestHeaders['x-admin-token'] ??
|
headers['x-session-token'] ?? headers['authorization'] ?? '';
|
||||||
ctx.requestHeaders['authorization'] ??
|
const raw = Array.isArray(provided) ? provided[0] ?? '' : (provided as string);
|
||||||
'';
|
const token = raw.startsWith('Bearer ') ? raw.slice(7) : raw;
|
||||||
const token =
|
const trimmed = token.trim();
|
||||||
Array.isArray(provided) ? provided[0] ?? '' : (provided as string);
|
return trimmed ? trimmed : null;
|
||||||
if (!token || token !== ctx.adminToken) {
|
};
|
||||||
|
|
||||||
|
const isFirstUser = async (ctx: GatewayApiContext, userId: string): Promise<boolean> => {
|
||||||
|
const first = await ctx.prisma.appUser.findFirst({
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return first?.id === userId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveAdminAuth = async (ctx: GatewayApiContext): Promise<AdminAuthContext> => {
|
||||||
|
const token = readSessionToken(ctx.requestHeaders);
|
||||||
|
if (!token) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'UNAUTHORIZED',
|
code: 'UNAUTHORIZED',
|
||||||
message: 'Invalid admin token.',
|
message: 'Session token is required.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const session = await ctx.sessions.getSession(token);
|
||||||
|
if (!session) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'Session is not valid.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const user = await ctx.users.findById(session.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'User not found.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const roles = user.roles;
|
||||||
|
const isSuperuser =
|
||||||
|
roles.includes(ROLE_SUPERUSER) ||
|
||||||
|
roles.includes(ADMIN_ROLE_SUPERUSER) ||
|
||||||
|
(await isFirstUser(ctx, session.userId));
|
||||||
|
const hasAdminRole =
|
||||||
|
isSuperuser ||
|
||||||
|
roles.some((role) => role === 'admin' || role.startsWith(ADMIN_ROLE_PREFIX));
|
||||||
|
if (!hasAdminRole) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Admin permission is required.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
roles,
|
||||||
|
isSuperuser,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireAdminAuth = (ctx: { adminAuth?: AdminAuthContext }): AdminAuthContext => {
|
||||||
|
if (!ctx.adminAuth) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'Admin session is not available.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ctx.adminAuth;
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleMatchesScope = (
|
||||||
|
role: string,
|
||||||
|
permission: string,
|
||||||
|
profileName?: string
|
||||||
|
): boolean => {
|
||||||
|
if (role === permission || role === `${permission}:*`) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (profileName && role === `${permission}:${profileName}`) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasScopedPermission = (
|
||||||
|
adminAuth: AdminAuthContext,
|
||||||
|
permission: string,
|
||||||
|
profileName?: string
|
||||||
|
): boolean => {
|
||||||
|
if (adminAuth.isSuperuser) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return adminAuth.roles.some((role: string) =>
|
||||||
|
roleMatchesScope(role, permission, profileName)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertPermission = (
|
||||||
|
adminAuth: AdminAuthContext,
|
||||||
|
permission: string,
|
||||||
|
profileName?: string
|
||||||
|
): void => {
|
||||||
|
if (hasScopedPermission(adminAuth, permission, profileName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Permission denied.',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminProcedure = procedure.use(async ({ ctx, next }) => {
|
||||||
|
const adminAuth = await resolveAdminAuth(ctx as GatewayApiContext);
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
...ctx,
|
||||||
|
adminAuth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const noticeAdminProcedure = adminProcedure.use(({ ctx, next }) => {
|
||||||
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
|
assertPermission(adminAuth, ROLE_ADMIN_NOTICE);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const userAdminProcedure = adminProcedure.use(({ ctx, next }) => {
|
||||||
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
|
assertPermission(adminAuth, ROLE_ADMIN_USERS);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileAdminProcedure = adminProcedure.use(({ ctx, next }) => {
|
||||||
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
|
assertPermission(adminAuth, ROLE_ADMIN_PROFILES);
|
||||||
return next();
|
return next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,7 +309,7 @@ export const adminRouter = router({
|
|||||||
});
|
});
|
||||||
return { notice: setting?.notice ?? '' };
|
return { notice: setting?.notice ?? '' };
|
||||||
}),
|
}),
|
||||||
setNotice: adminProcedure
|
setNotice: noticeAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
notice: z.string().max(4000),
|
notice: z.string().max(4000),
|
||||||
@@ -197,7 +330,7 @@ export const adminRouter = router({
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
users: router({
|
users: router({
|
||||||
lookup: adminProcedure.input(zUserLookupInput).query(async ({ ctx, input }) => {
|
lookup: userAdminProcedure.input(zUserLookupInput).query(async ({ ctx, input }) => {
|
||||||
const user =
|
const user =
|
||||||
input.id
|
input.id
|
||||||
? await ctx.users.findById(input.id)
|
? await ctx.users.findById(input.id)
|
||||||
@@ -221,7 +354,7 @@ export const adminRouter = router({
|
|||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
resetPassword: adminProcedure
|
resetPassword: userAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
@@ -237,7 +370,7 @@ export const adminRouter = router({
|
|||||||
);
|
);
|
||||||
return { password };
|
return { password };
|
||||||
}),
|
}),
|
||||||
updateRoles: adminProcedure
|
updateRoles: userAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
@@ -277,7 +410,7 @@ export const adminRouter = router({
|
|||||||
);
|
);
|
||||||
return { roles: nextRoles };
|
return { roles: nextRoles };
|
||||||
}),
|
}),
|
||||||
updateSanctions: adminProcedure
|
updateSanctions: userAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
@@ -300,7 +433,7 @@ export const adminRouter = router({
|
|||||||
);
|
);
|
||||||
return { sanctions: next };
|
return { sanctions: next };
|
||||||
}),
|
}),
|
||||||
setServerRestriction: adminProcedure
|
setServerRestriction: userAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
@@ -329,7 +462,7 @@ export const adminRouter = router({
|
|||||||
);
|
);
|
||||||
return { sanctions: next };
|
return { sanctions: next };
|
||||||
}),
|
}),
|
||||||
resetProfileIcon: adminProcedure
|
resetProfileIcon: userAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
@@ -353,7 +486,7 @@ export const adminRouter = router({
|
|||||||
);
|
);
|
||||||
return { profileIconResetAt: next.profileIconResetAt };
|
return { profileIconResetAt: next.profileIconResetAt };
|
||||||
}),
|
}),
|
||||||
forceDelete: adminProcedure
|
forceDelete: userAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
@@ -386,7 +519,7 @@ export const adminRouter = router({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
upsert: adminProcedure
|
upsert: profileAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
profile: z.string().min(1).max(32),
|
profile: z.string().min(1).max(32),
|
||||||
@@ -412,7 +545,7 @@ export const adminRouter = router({
|
|||||||
buildCommitSha: input.buildCommitSha,
|
buildCommitSha: input.buildCommitSha,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
setStatus: adminProcedure
|
setStatus: profileAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
profileName: z.string().min(1),
|
profileName: z.string().min(1),
|
||||||
@@ -448,7 +581,7 @@ export const adminRouter = router({
|
|||||||
await ctx.orchestrator.reconcileNow();
|
await ctx.orchestrator.reconcileNow();
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
updateMeta: adminProcedure
|
updateMeta: profileAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
profileName: z.string().min(1),
|
profileName: z.string().min(1),
|
||||||
@@ -483,6 +616,7 @@ export const adminRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
if (
|
if (
|
||||||
(input.action === 'ACCELERATE' || input.action === 'DELAY') &&
|
(input.action === 'ACCELERATE' || input.action === 'DELAY') &&
|
||||||
!input.durationMinutes
|
!input.durationMinutes
|
||||||
@@ -506,6 +640,65 @@ export const adminRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canManageProfiles = hasScopedPermission(
|
||||||
|
adminAuth,
|
||||||
|
ROLE_ADMIN_PROFILES,
|
||||||
|
profile.profileName
|
||||||
|
);
|
||||||
|
const canResume =
|
||||||
|
canManageProfiles ||
|
||||||
|
hasScopedPermission(
|
||||||
|
adminAuth,
|
||||||
|
ROLE_RESUME_WHEN_STOPPED,
|
||||||
|
profile.profileName
|
||||||
|
);
|
||||||
|
const canResetSchedule =
|
||||||
|
canManageProfiles ||
|
||||||
|
hasScopedPermission(adminAuth, ROLE_RESET_SCHEDULE, profile.profileName);
|
||||||
|
const canOpenSurvey =
|
||||||
|
canManageProfiles ||
|
||||||
|
hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName);
|
||||||
|
|
||||||
|
if (input.action === 'RESUME') {
|
||||||
|
if (profile.status !== 'STOPPED') {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Resume is allowed only for STOPPED profiles.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!canResume) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Resume permission is required.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (input.action === 'RESET_SCHEDULED') {
|
||||||
|
if (profile.status !== 'COMPLETED') {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Reset scheduling is allowed only for COMPLETED profiles.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!canResetSchedule) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Reset scheduling permission is required.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (input.action === 'OPEN_SURVEY') {
|
||||||
|
if (!canOpenSurvey) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Survey permission is required.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!canManageProfiles) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Profile management permission is required.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const statusMap = {
|
const statusMap = {
|
||||||
RESUME: 'RUNNING',
|
RESUME: 'RUNNING',
|
||||||
PAUSE: 'PAUSED',
|
PAUSE: 'PAUSED',
|
||||||
@@ -537,7 +730,7 @@ export const adminRouter = router({
|
|||||||
await ctx.profiles.updateMeta(input.profileName, nextMeta);
|
await ctx.profiles.updateMeta(input.profileName, nextMeta);
|
||||||
return { ok: true, action: actionRecord };
|
return { ok: true, action: actionRecord };
|
||||||
}),
|
}),
|
||||||
requestBuild: adminProcedure
|
requestBuild: profileAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
profileName: z.string().min(1),
|
profileName: z.string().min(1),
|
||||||
@@ -557,7 +750,7 @@ export const adminRouter = router({
|
|||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
setBuildStatus: adminProcedure
|
setBuildStatus: profileAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
profileName: z.string().min(1),
|
profileName: z.string().min(1),
|
||||||
@@ -567,11 +760,11 @@ export const adminRouter = router({
|
|||||||
.mutation(async ({ ctx, input }) =>
|
.mutation(async ({ ctx, input }) =>
|
||||||
ctx.profiles.updateBuildStatus(input.profileName, input.status)
|
ctx.profiles.updateBuildStatus(input.profileName, input.status)
|
||||||
),
|
),
|
||||||
reconcileNow: adminProcedure.mutation(async ({ ctx }) => {
|
reconcileNow: profileAdminProcedure.mutation(async ({ ctx }) => {
|
||||||
await ctx.orchestrator.reconcileNow();
|
await ctx.orchestrator.reconcileNow();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
cleanupWorkspaces: adminProcedure.mutation(async ({ ctx }) => {
|
cleanupWorkspaces: profileAdminProcedure.mutation(async ({ ctx }) => {
|
||||||
const result = await ctx.orchestrator.cleanupStaleWorkspaces();
|
const result = await ctx.orchestrator.cleanupStaleWorkspaces();
|
||||||
return {
|
return {
|
||||||
removed: result.removed,
|
removed: result.removed,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export interface GatewayApiConfig {
|
|||||||
kakaoAdminKey?: string;
|
kakaoAdminKey?: string;
|
||||||
kakaoRedirectUri: string;
|
kakaoRedirectUri: string;
|
||||||
publicBaseUrl: string;
|
publicBaseUrl: string;
|
||||||
adminToken?: string;
|
|
||||||
orchestratorEnabled: boolean;
|
orchestratorEnabled: boolean;
|
||||||
orchestratorReconcileIntervalMs: number;
|
orchestratorReconcileIntervalMs: number;
|
||||||
orchestratorScheduleIntervalMs: number;
|
orchestratorScheduleIntervalMs: number;
|
||||||
@@ -105,7 +104,6 @@ export const resolveGatewayApiConfigFromEnv = (
|
|||||||
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
|
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
|
||||||
kakaoRedirectUri,
|
kakaoRedirectUri,
|
||||||
publicBaseUrl,
|
publicBaseUrl,
|
||||||
adminToken: env.GATEWAY_ADMIN_TOKEN,
|
|
||||||
orchestratorEnabled: parseBoolean(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
|
orchestratorEnabled: parseBoolean(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
|
||||||
orchestratorReconcileIntervalMs: parseNumber(
|
orchestratorReconcileIntervalMs: parseNumber(
|
||||||
env.GATEWAY_ORCHESTRATOR_RECONCILE_MS,
|
env.GATEWAY_ORCHESTRATOR_RECONCILE_MS,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { GatewayProfileRepository } from './orchestrator/profileRepository.
|
|||||||
import type { GatewayOrchestratorHandle } from './orchestrator/gatewayOrchestrator.js';
|
import type { GatewayOrchestratorHandle } from './orchestrator/gatewayOrchestrator.js';
|
||||||
import type { GatewayProfileStatusService } from './lobby/profileStatusService.js';
|
import type { GatewayProfileStatusService } from './lobby/profileStatusService.js';
|
||||||
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||||
|
import type { AdminAuthContext } from './adminAuth.js';
|
||||||
|
|
||||||
export interface GatewayApiContext {
|
export interface GatewayApiContext {
|
||||||
users: UserRepository;
|
users: UserRepository;
|
||||||
@@ -20,9 +21,9 @@ export interface GatewayApiContext {
|
|||||||
profiles: GatewayProfileRepository;
|
profiles: GatewayProfileRepository;
|
||||||
orchestrator: GatewayOrchestratorHandle;
|
orchestrator: GatewayOrchestratorHandle;
|
||||||
profileStatus: GatewayProfileStatusService;
|
profileStatus: GatewayProfileStatusService;
|
||||||
adminToken?: string;
|
|
||||||
requestHeaders: Record<string, string | string[] | undefined>;
|
requestHeaders: Record<string, string | string[] | undefined>;
|
||||||
prisma: GatewayPrismaClient;
|
prisma: GatewayPrismaClient;
|
||||||
|
adminAuth?: AdminAuthContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createGatewayApiContext = (options: {
|
export const createGatewayApiContext = (options: {
|
||||||
@@ -37,7 +38,6 @@ export const createGatewayApiContext = (options: {
|
|||||||
profiles: GatewayProfileRepository;
|
profiles: GatewayProfileRepository;
|
||||||
orchestrator: GatewayOrchestratorHandle;
|
orchestrator: GatewayOrchestratorHandle;
|
||||||
profileStatus: GatewayProfileStatusService;
|
profileStatus: GatewayProfileStatusService;
|
||||||
adminToken?: string;
|
|
||||||
requestHeaders?: Record<string, string | string[] | undefined>;
|
requestHeaders?: Record<string, string | string[] | undefined>;
|
||||||
prisma: GatewayPrismaClient;
|
prisma: GatewayPrismaClient;
|
||||||
}): GatewayApiContext => ({
|
}): GatewayApiContext => ({
|
||||||
@@ -52,7 +52,6 @@ export const createGatewayApiContext = (options: {
|
|||||||
profiles: options.profiles,
|
profiles: options.profiles,
|
||||||
orchestrator: options.orchestrator,
|
orchestrator: options.orchestrator,
|
||||||
profileStatus: options.profileStatus,
|
profileStatus: options.profileStatus,
|
||||||
adminToken: options.adminToken,
|
|
||||||
requestHeaders: options.requestHeaders ?? {},
|
requestHeaders: options.requestHeaders ?? {},
|
||||||
prisma: options.prisma,
|
prisma: options.prisma,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ export const createGatewayApiServer = async () => {
|
|||||||
profiles,
|
profiles,
|
||||||
orchestrator,
|
orchestrator,
|
||||||
profileStatus,
|
profileStatus,
|
||||||
adminToken: config.adminToken,
|
|
||||||
requestHeaders: req.headers,
|
requestHeaders: req.headers,
|
||||||
prisma: postgres.prisma as GatewayPrismaClient,
|
prisma: postgres.prisma as GatewayPrismaClient,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
||||||
import type { AppRouter } from '../../../gateway-api/src/router';
|
import type { AppRouter } from '../../../gateway-api/src/router';
|
||||||
|
|
||||||
const getAdminToken = (): string | null => {
|
const getSessionToken = (): string | null => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return window.localStorage.getItem('sammo-admin-token');
|
return window.localStorage.getItem('sammo-session-token');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const trpc = createTRPCProxyClient<AppRouter>({
|
export const trpc = createTRPCProxyClient<AppRouter>({
|
||||||
@@ -13,8 +13,8 @@ export const trpc = createTRPCProxyClient<AppRouter>({
|
|||||||
httpBatchLink({
|
httpBatchLink({
|
||||||
url: '/api/trpc', // 실제 환경에 맞게 조정 필요
|
url: '/api/trpc', // 실제 환경에 맞게 조정 필요
|
||||||
headers() {
|
headers() {
|
||||||
const token = getAdminToken();
|
const token = getSessionToken();
|
||||||
return token ? { 'x-admin-token': token } : {};
|
return token ? { 'x-session-token': token } : {};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ type AdminAction =
|
|||||||
| 'DELAY'
|
| 'DELAY'
|
||||||
| 'RESET_NOW'
|
| 'RESET_NOW'
|
||||||
| 'RESET_SCHEDULED'
|
| 'RESET_SCHEDULED'
|
||||||
|
| 'OPEN_SURVEY'
|
||||||
| 'SHUTDOWN';
|
| 'SHUTDOWN';
|
||||||
|
|
||||||
type AdminClient = {
|
type AdminClient = {
|
||||||
@@ -141,23 +142,23 @@ type AdminClient = {
|
|||||||
|
|
||||||
const adminClient = trpc.admin as unknown as AdminClient;
|
const adminClient = trpc.admin as unknown as AdminClient;
|
||||||
|
|
||||||
const adminToken = ref('');
|
const sessionToken = ref('');
|
||||||
const adminTokenStatus = ref('');
|
const sessionTokenStatus = ref('');
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
adminToken.value = window.localStorage.getItem('sammo-admin-token') ?? '';
|
sessionToken.value = window.localStorage.getItem('sammo-session-token') ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveAdminToken = () => {
|
const saveSessionToken = () => {
|
||||||
const value = adminToken.value.trim();
|
const value = sessionToken.value.trim();
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
if (value) {
|
if (value) {
|
||||||
window.localStorage.setItem('sammo-admin-token', value);
|
window.localStorage.setItem('sammo-session-token', value);
|
||||||
} else {
|
} else {
|
||||||
window.localStorage.removeItem('sammo-admin-token');
|
window.localStorage.removeItem('sammo-session-token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
adminTokenStatus.value = value ? '저장됨' : '삭제됨';
|
sessionTokenStatus.value = value ? '저장됨' : '삭제됨';
|
||||||
};
|
};
|
||||||
|
|
||||||
const noticeDraft = ref('');
|
const noticeDraft = ref('');
|
||||||
@@ -559,19 +560,19 @@ onMounted(() => {
|
|||||||
|
|
||||||
<section class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-3">
|
<section class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-lg font-semibold">관리자 토큰</h3>
|
<h3 class="text-lg font-semibold">관리자 세션 토큰</h3>
|
||||||
<span class="text-xs text-zinc-500">{{ adminTokenStatus }}</span>
|
<span class="text-xs text-zinc-500">{{ sessionTokenStatus }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col md:flex-row gap-3">
|
<div class="flex flex-col md:flex-row gap-3">
|
||||||
<input
|
<input
|
||||||
v-model="adminToken"
|
v-model="sessionToken"
|
||||||
type="password"
|
type="password"
|
||||||
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
|
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
|
||||||
placeholder="GATEWAY_ADMIN_TOKEN 입력"
|
placeholder="세션 토큰 입력"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="bg-yellow-600 hover:bg-yellow-500 text-black font-semibold px-4 py-2 rounded"
|
class="bg-yellow-600 hover:bg-yellow-500 text-black font-semibold px-4 py-2 rounded"
|
||||||
@click="saveAdminToken"
|
@click="saveSessionToken"
|
||||||
>
|
>
|
||||||
저장
|
저장
|
||||||
</button>
|
</button>
|
||||||
@@ -954,6 +955,12 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
리셋 예약
|
리셋 예약
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-teal-700 hover:bg-teal-600 text-white font-semibold px-3 py-2 rounded"
|
||||||
|
@click="requestProfileAction(profile.profileName, 'OPEN_SURVEY')"
|
||||||
|
>
|
||||||
|
설문 오픈
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="bg-black hover:bg-zinc-800 text-white font-semibold px-3 py-2 rounded col-span-2"
|
class="bg-black hover:bg-zinc-800 text-white font-semibold px-3 py-2 rounded col-span-2"
|
||||||
@click="requestProfileAction(profile.profileName, 'SHUTDOWN')"
|
@click="requestProfileAction(profile.profileName, 'SHUTDOWN')"
|
||||||
|
|||||||
Reference in New Issue
Block a user