feat: 로컬 계정 생성 기능 추가; 환경 설정에 따라 계정 생성 가능 여부 제어
This commit is contained in:
@@ -6,6 +6,7 @@ import { z } from 'zod';
|
|||||||
import { procedure, router } from './trpc.js';
|
import { procedure, router } from './trpc.js';
|
||||||
import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
|
import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
|
||||||
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
||||||
|
import { toPublicUser } from './auth/userRepository.js';
|
||||||
import type { AdminAuthContext } from './adminAuth.js';
|
import type { AdminAuthContext } from './adminAuth.js';
|
||||||
import type { GatewayApiContext } from './context.js';
|
import type { GatewayApiContext } from './context.js';
|
||||||
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
|
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
|
||||||
@@ -41,6 +42,7 @@ const ADMIN_ROLE_PREFIX = 'admin.';
|
|||||||
const ADMIN_ROLE_SUPERUSER = 'admin.superuser';
|
const ADMIN_ROLE_SUPERUSER = 'admin.superuser';
|
||||||
const ROLE_SUPERUSER = 'superuser';
|
const ROLE_SUPERUSER = 'superuser';
|
||||||
const ROLE_ADMIN_USERS = 'admin.users.manage';
|
const ROLE_ADMIN_USERS = 'admin.users.manage';
|
||||||
|
const ROLE_ADMIN_USERS_CREATE = 'admin.users.create';
|
||||||
const ROLE_ADMIN_PROFILES = 'admin.profiles.manage';
|
const ROLE_ADMIN_PROFILES = 'admin.profiles.manage';
|
||||||
const ROLE_ADMIN_NOTICE = 'admin.notice.manage';
|
const ROLE_ADMIN_NOTICE = 'admin.notice.manage';
|
||||||
const ROLE_RESET_SCHEDULE = 'admin.reset.schedule';
|
const ROLE_RESET_SCHEDULE = 'admin.reset.schedule';
|
||||||
@@ -142,6 +144,20 @@ const assertPermission = (adminAuth: AdminAuthContext, permission: string, profi
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canCreateLocalUser = (adminAuth: AdminAuthContext): boolean =>
|
||||||
|
hasScopedPermission(adminAuth, ROLE_ADMIN_USERS_CREATE) || hasScopedPermission(adminAuth, ROLE_ADMIN_USERS);
|
||||||
|
|
||||||
|
// 로컬 계정 임의 생성은 환경 설정이 켜져 있을 때만 허용한다.
|
||||||
|
const assertLocalAccountEnabled = (ctx: GatewayApiContext): void => {
|
||||||
|
if (ctx.adminLocalAccountEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Local account provisioning is disabled.',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const adminProcedure = procedure.use(async ({ ctx, next }) => {
|
const adminProcedure = procedure.use(async ({ ctx, next }) => {
|
||||||
const adminAuth = await resolveAdminAuth(ctx as GatewayApiContext);
|
const adminAuth = await resolveAdminAuth(ctx as GatewayApiContext);
|
||||||
return next({
|
return next({
|
||||||
@@ -164,6 +180,17 @@ const userAdminProcedure = adminProcedure.use(({ ctx, next }) => {
|
|||||||
return next();
|
return next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const userCreateProcedure = adminProcedure.use(({ ctx, next }) => {
|
||||||
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
|
if (!canCreateLocalUser(adminAuth)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Permission denied.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
const profileAdminProcedure = adminProcedure.use(({ ctx, next }) => {
|
const profileAdminProcedure = adminProcedure.use(({ ctx, next }) => {
|
||||||
const adminAuth = requireAdminAuth(ctx);
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
assertPermission(adminAuth, ROLE_ADMIN_PROFILES);
|
assertPermission(adminAuth, ROLE_ADMIN_PROFILES);
|
||||||
@@ -198,6 +225,12 @@ const zSanctionsPatch = z.object({
|
|||||||
serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(),
|
serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const zLocalAccountInput = z.object({
|
||||||
|
username: z.string().min(2).max(32),
|
||||||
|
password: z.string().min(6).max(128),
|
||||||
|
displayName: z.string().min(2).max(40).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const zInstallAutorun = z.object({
|
const zInstallAutorun = z.object({
|
||||||
limitMinutes: z.number().int().min(0).max(43200),
|
limitMinutes: z.number().int().min(0).max(43200),
|
||||||
options: z.array(z.enum(AUTORUN_USER_OPTIONS)),
|
options: z.array(z.enum(AUTORUN_USER_OPTIONS)),
|
||||||
@@ -339,6 +372,34 @@ export const adminRouter = router({
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
users: router({
|
users: router({
|
||||||
|
getLocalAccountStatus: adminProcedure.query(({ ctx }) => ({
|
||||||
|
enabled: (ctx as GatewayApiContext).adminLocalAccountEnabled,
|
||||||
|
})),
|
||||||
|
createLocal: userCreateProcedure.input(zLocalAccountInput).mutation(async ({ ctx, input }) => {
|
||||||
|
const gatewayCtx = ctx as GatewayApiContext;
|
||||||
|
assertLocalAccountEnabled(gatewayCtx);
|
||||||
|
const existing = await gatewayCtx.users.findByUsername(input.username);
|
||||||
|
if (existing) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'Username already exists.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await gatewayCtx.users.createUser({
|
||||||
|
username: input.username,
|
||||||
|
password: input.password,
|
||||||
|
displayName: input.displayName,
|
||||||
|
});
|
||||||
|
return { user: toPublicUser(created) };
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'Username already exists.',
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
lookup: userAdminProcedure.input(zUserLookupInput).query(async ({ ctx, input }) => {
|
lookup: userAdminProcedure.input(zUserLookupInput).query(async ({ ctx, input }) => {
|
||||||
const user = input.id
|
const user = input.id
|
||||||
? await ctx.users.findById(input.id)
|
? await ctx.users.findById(input.id)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface GatewayApiConfig {
|
|||||||
kakaoAdminKey?: string;
|
kakaoAdminKey?: string;
|
||||||
kakaoRedirectUri: string;
|
kakaoRedirectUri: string;
|
||||||
publicBaseUrl: string;
|
publicBaseUrl: string;
|
||||||
|
adminLocalAccountEnabled: boolean;
|
||||||
orchestratorEnabled: boolean;
|
orchestratorEnabled: boolean;
|
||||||
orchestratorReconcileIntervalMs: number;
|
orchestratorReconcileIntervalMs: number;
|
||||||
orchestratorScheduleIntervalMs: number;
|
orchestratorScheduleIntervalMs: number;
|
||||||
@@ -80,6 +81,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
|||||||
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
|
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
|
||||||
kakaoRedirectUri,
|
kakaoRedirectUri,
|
||||||
publicBaseUrl,
|
publicBaseUrl,
|
||||||
|
adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false),
|
||||||
orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
|
orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
|
||||||
orchestratorReconcileIntervalMs: parseNumberWithFallback(
|
orchestratorReconcileIntervalMs: parseNumberWithFallback(
|
||||||
env.GATEWAY_ORCHESTRATOR_RECONCILE_MS,
|
env.GATEWAY_ORCHESTRATOR_RECONCILE_MS,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface GatewayApiContext {
|
|||||||
kakaoClient: KakaoOAuthClient;
|
kakaoClient: KakaoOAuthClient;
|
||||||
oauthSessions: OAuthSessionStore;
|
oauthSessions: OAuthSessionStore;
|
||||||
publicBaseUrl: string;
|
publicBaseUrl: string;
|
||||||
|
adminLocalAccountEnabled: boolean;
|
||||||
profiles: GatewayProfileRepository;
|
profiles: GatewayProfileRepository;
|
||||||
orchestrator: GatewayOrchestratorHandle;
|
orchestrator: GatewayOrchestratorHandle;
|
||||||
profileStatus: GatewayProfileStatusService;
|
profileStatus: GatewayProfileStatusService;
|
||||||
@@ -35,6 +36,7 @@ export const createGatewayApiContext = (options: {
|
|||||||
kakaoClient: KakaoOAuthClient;
|
kakaoClient: KakaoOAuthClient;
|
||||||
oauthSessions: OAuthSessionStore;
|
oauthSessions: OAuthSessionStore;
|
||||||
publicBaseUrl: string;
|
publicBaseUrl: string;
|
||||||
|
adminLocalAccountEnabled: boolean;
|
||||||
profiles: GatewayProfileRepository;
|
profiles: GatewayProfileRepository;
|
||||||
orchestrator: GatewayOrchestratorHandle;
|
orchestrator: GatewayOrchestratorHandle;
|
||||||
profileStatus: GatewayProfileStatusService;
|
profileStatus: GatewayProfileStatusService;
|
||||||
@@ -49,6 +51,7 @@ export const createGatewayApiContext = (options: {
|
|||||||
kakaoClient: options.kakaoClient,
|
kakaoClient: options.kakaoClient,
|
||||||
oauthSessions: options.oauthSessions,
|
oauthSessions: options.oauthSessions,
|
||||||
publicBaseUrl: options.publicBaseUrl,
|
publicBaseUrl: options.publicBaseUrl,
|
||||||
|
adminLocalAccountEnabled: options.adminLocalAccountEnabled,
|
||||||
profiles: options.profiles,
|
profiles: options.profiles,
|
||||||
orchestrator: options.orchestrator,
|
orchestrator: options.orchestrator,
|
||||||
profileStatus: options.profileStatus,
|
profileStatus: options.profileStatus,
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export const createGatewayApiServer = async () => {
|
|||||||
kakaoClient,
|
kakaoClient,
|
||||||
oauthSessions,
|
oauthSessions,
|
||||||
publicBaseUrl: config.publicBaseUrl,
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
|
adminLocalAccountEnabled: config.adminLocalAccountEnabled,
|
||||||
profiles,
|
profiles,
|
||||||
orchestrator,
|
orchestrator,
|
||||||
profileStatus,
|
profileStatus,
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ const buildCaller = () => {
|
|||||||
kakaoClient: kakaoClient as unknown as KakaoOAuthClient,
|
kakaoClient: kakaoClient as unknown as KakaoOAuthClient,
|
||||||
oauthSessions,
|
oauthSessions,
|
||||||
publicBaseUrl: 'http://localhost',
|
publicBaseUrl: 'http://localhost',
|
||||||
|
adminLocalAccountEnabled: false,
|
||||||
profiles,
|
profiles,
|
||||||
orchestrator,
|
orchestrator,
|
||||||
profileStatus,
|
profileStatus,
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ type AdminUser = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AdminPublicUser = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
roles: string[];
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
type AdminProfile = {
|
type AdminProfile = {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
@@ -133,6 +141,16 @@ type AdminClient = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
users: {
|
users: {
|
||||||
|
getLocalAccountStatus: {
|
||||||
|
query: () => Promise<{ enabled: boolean }>;
|
||||||
|
};
|
||||||
|
createLocal: {
|
||||||
|
mutate: (input: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
displayName?: string;
|
||||||
|
}) => Promise<{ user: AdminPublicUser }>;
|
||||||
|
};
|
||||||
lookup: {
|
lookup: {
|
||||||
query: (input: { id?: string; username?: string; email?: string }) => Promise<AdminUser | null>;
|
query: (input: { id?: string; username?: string; email?: string }) => Promise<AdminUser | null>;
|
||||||
};
|
};
|
||||||
@@ -297,6 +315,16 @@ const userLoading = ref(false);
|
|||||||
const userError = ref('');
|
const userError = ref('');
|
||||||
const userResult = ref<AdminUser | null>(null);
|
const userResult = ref<AdminUser | null>(null);
|
||||||
|
|
||||||
|
const localAccountEnabled = ref(false);
|
||||||
|
const localAccountStatus = ref('');
|
||||||
|
const localAccountResult = ref('');
|
||||||
|
const localAccountLoading = ref(false);
|
||||||
|
const localAccountForm = ref({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
displayName: '',
|
||||||
|
});
|
||||||
|
|
||||||
const passwordInput = ref('');
|
const passwordInput = ref('');
|
||||||
const passwordResult = ref('');
|
const passwordResult = ref('');
|
||||||
const passwordStatus = ref('');
|
const passwordStatus = ref('');
|
||||||
@@ -322,6 +350,18 @@ const forceDeleteStatus = ref('');
|
|||||||
|
|
||||||
const hasUser = computed(() => Boolean(userResult.value));
|
const hasUser = computed(() => Boolean(userResult.value));
|
||||||
|
|
||||||
|
const loadLocalAccountStatus = async () => {
|
||||||
|
localAccountStatus.value = '';
|
||||||
|
try {
|
||||||
|
const result = await adminClient.users.getLocalAccountStatus.query();
|
||||||
|
localAccountEnabled.value = result.enabled;
|
||||||
|
localAccountStatus.value = result.enabled ? '' : 'ENV 설정이 비활성화 상태입니다.';
|
||||||
|
} catch (error) {
|
||||||
|
localAccountEnabled.value = false;
|
||||||
|
localAccountStatus.value = '로컬 계정 생성 설정 확인 실패';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadNotice = async () => {
|
const loadNotice = async () => {
|
||||||
noticeLoading.value = true;
|
noticeLoading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -908,7 +948,46 @@ const forceDeleteUser = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createLocalAccount = async () => {
|
||||||
|
localAccountStatus.value = '';
|
||||||
|
localAccountResult.value = '';
|
||||||
|
if (!localAccountEnabled.value) {
|
||||||
|
localAccountStatus.value = 'ENV 설정이 비활성화 상태입니다.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const username = localAccountForm.value.username.trim();
|
||||||
|
const password = localAccountForm.value.password.trim();
|
||||||
|
const displayName = localAccountForm.value.displayName.trim();
|
||||||
|
if (!username || !password) {
|
||||||
|
localAccountStatus.value = '아이디와 비밀번호를 입력하세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localAccountLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await adminClient.users.createLocal.mutate({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
displayName: displayName || undefined,
|
||||||
|
});
|
||||||
|
localAccountResult.value = `생성됨: ${result.user.username} (${result.user.id})`;
|
||||||
|
localAccountStatus.value = '로컬 계정 생성 완료';
|
||||||
|
localAccountForm.value = {
|
||||||
|
username: result.user.username,
|
||||||
|
password: '',
|
||||||
|
displayName: '',
|
||||||
|
};
|
||||||
|
userLookupMode.value = 'username';
|
||||||
|
userLookupValue.value = result.user.username;
|
||||||
|
await lookupUser();
|
||||||
|
} catch (error) {
|
||||||
|
localAccountStatus.value = '로컬 계정 생성 실패';
|
||||||
|
} finally {
|
||||||
|
localAccountLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
void loadLocalAccountStatus();
|
||||||
void loadNotice();
|
void loadNotice();
|
||||||
void loadProfiles();
|
void loadProfiles();
|
||||||
});
|
});
|
||||||
@@ -992,6 +1071,52 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h4 class="text-base font-semibold">로컬 계정 생성</h4>
|
||||||
|
<span class="text-xs text-zinc-500">
|
||||||
|
ENV {{ localAccountEnabled ? 'ON' : 'OFF' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">
|
||||||
|
카카오 OAuth 없이 로그인 가능한 계정을 생성합니다.
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<input
|
||||||
|
v-model="localAccountForm.username"
|
||||||
|
type="text"
|
||||||
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
placeholder="아이디"
|
||||||
|
:disabled="!localAccountEnabled || localAccountLoading"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="localAccountForm.password"
|
||||||
|
type="password"
|
||||||
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
placeholder="비밀번호"
|
||||||
|
:disabled="!localAccountEnabled || localAccountLoading"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="localAccountForm.displayName"
|
||||||
|
type="text"
|
||||||
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
placeholder="표시명 (선택)"
|
||||||
|
:disabled="!localAccountEnabled || localAccountLoading"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="bg-cyan-600 hover:bg-cyan-500 text-black font-semibold px-4 py-2 rounded"
|
||||||
|
:disabled="!localAccountEnabled || localAccountLoading"
|
||||||
|
@click="createLocalAccount"
|
||||||
|
>
|
||||||
|
계정 생성
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">{{ localAccountStatus }}</div>
|
||||||
|
<div v-if="localAccountResult" class="text-xs text-emerald-400">
|
||||||
|
{{ localAccountResult }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||||
<h4 class="text-base font-semibold">비밀번호 리셋</h4>
|
<h4 class="text-base font-semibold">비밀번호 리셋</h4>
|
||||||
<div class="flex flex-col md:flex-row gap-2">
|
<div class="flex flex-col md:flex-row gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user