feat: 로컬 계정 생성 기능 추가; 환경 설정에 따라 계정 생성 가능 여부 제어
This commit is contained in:
@@ -6,6 +6,7 @@ import { z } from 'zod';
|
||||
import { procedure, router } from './trpc.js';
|
||||
import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
|
||||
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
||||
import { toPublicUser } from './auth/userRepository.js';
|
||||
import type { AdminAuthContext } from './adminAuth.js';
|
||||
import type { GatewayApiContext } from './context.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 ROLE_SUPERUSER = 'superuser';
|
||||
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_NOTICE = 'admin.notice.manage';
|
||||
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 adminAuth = await resolveAdminAuth(ctx as GatewayApiContext);
|
||||
return next({
|
||||
@@ -164,6 +180,17 @@ const userAdminProcedure = adminProcedure.use(({ ctx, 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 adminAuth = requireAdminAuth(ctx);
|
||||
assertPermission(adminAuth, ROLE_ADMIN_PROFILES);
|
||||
@@ -198,6 +225,12 @@ const zSanctionsPatch = z.object({
|
||||
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({
|
||||
limitMinutes: z.number().int().min(0).max(43200),
|
||||
options: z.array(z.enum(AUTORUN_USER_OPTIONS)),
|
||||
@@ -339,6 +372,34 @@ export const adminRouter = 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 }) => {
|
||||
const user = input.id
|
||||
? await ctx.users.findById(input.id)
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface GatewayApiConfig {
|
||||
kakaoAdminKey?: string;
|
||||
kakaoRedirectUri: string;
|
||||
publicBaseUrl: string;
|
||||
adminLocalAccountEnabled: boolean;
|
||||
orchestratorEnabled: boolean;
|
||||
orchestratorReconcileIntervalMs: number;
|
||||
orchestratorScheduleIntervalMs: number;
|
||||
@@ -80,6 +81,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
|
||||
kakaoRedirectUri,
|
||||
publicBaseUrl,
|
||||
adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false),
|
||||
orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
|
||||
orchestratorReconcileIntervalMs: parseNumberWithFallback(
|
||||
env.GATEWAY_ORCHESTRATOR_RECONCILE_MS,
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface GatewayApiContext {
|
||||
kakaoClient: KakaoOAuthClient;
|
||||
oauthSessions: OAuthSessionStore;
|
||||
publicBaseUrl: string;
|
||||
adminLocalAccountEnabled: boolean;
|
||||
profiles: GatewayProfileRepository;
|
||||
orchestrator: GatewayOrchestratorHandle;
|
||||
profileStatus: GatewayProfileStatusService;
|
||||
@@ -35,6 +36,7 @@ export const createGatewayApiContext = (options: {
|
||||
kakaoClient: KakaoOAuthClient;
|
||||
oauthSessions: OAuthSessionStore;
|
||||
publicBaseUrl: string;
|
||||
adminLocalAccountEnabled: boolean;
|
||||
profiles: GatewayProfileRepository;
|
||||
orchestrator: GatewayOrchestratorHandle;
|
||||
profileStatus: GatewayProfileStatusService;
|
||||
@@ -49,6 +51,7 @@ export const createGatewayApiContext = (options: {
|
||||
kakaoClient: options.kakaoClient,
|
||||
oauthSessions: options.oauthSessions,
|
||||
publicBaseUrl: options.publicBaseUrl,
|
||||
adminLocalAccountEnabled: options.adminLocalAccountEnabled,
|
||||
profiles: options.profiles,
|
||||
orchestrator: options.orchestrator,
|
||||
profileStatus: options.profileStatus,
|
||||
|
||||
@@ -75,6 +75,7 @@ export const createGatewayApiServer = async () => {
|
||||
kakaoClient,
|
||||
oauthSessions,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
adminLocalAccountEnabled: config.adminLocalAccountEnabled,
|
||||
profiles,
|
||||
orchestrator,
|
||||
profileStatus,
|
||||
|
||||
@@ -82,6 +82,7 @@ const buildCaller = () => {
|
||||
kakaoClient: kakaoClient as unknown as KakaoOAuthClient,
|
||||
oauthSessions,
|
||||
publicBaseUrl: 'http://localhost',
|
||||
adminLocalAccountEnabled: false,
|
||||
profiles,
|
||||
orchestrator,
|
||||
profileStatus,
|
||||
|
||||
@@ -53,6 +53,14 @@ type AdminUser = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type AdminPublicUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type AdminProfile = {
|
||||
profileName: string;
|
||||
profile: string;
|
||||
@@ -133,6 +141,16 @@ type AdminClient = {
|
||||
};
|
||||
};
|
||||
users: {
|
||||
getLocalAccountStatus: {
|
||||
query: () => Promise<{ enabled: boolean }>;
|
||||
};
|
||||
createLocal: {
|
||||
mutate: (input: {
|
||||
username: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
}) => Promise<{ user: AdminPublicUser }>;
|
||||
};
|
||||
lookup: {
|
||||
query: (input: { id?: string; username?: string; email?: string }) => Promise<AdminUser | null>;
|
||||
};
|
||||
@@ -297,6 +315,16 @@ const userLoading = ref(false);
|
||||
const userError = ref('');
|
||||
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 passwordResult = ref('');
|
||||
const passwordStatus = ref('');
|
||||
@@ -322,6 +350,18 @@ const forceDeleteStatus = ref('');
|
||||
|
||||
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 () => {
|
||||
noticeLoading.value = true;
|
||||
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(() => {
|
||||
void loadLocalAccountStatus();
|
||||
void loadNotice();
|
||||
void loadProfiles();
|
||||
});
|
||||
@@ -992,6 +1071,52 @@ onMounted(() => {
|
||||
</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">
|
||||
<h4 class="text-base font-semibold">비밀번호 리셋</h4>
|
||||
<div class="flex flex-col md:flex-row gap-2">
|
||||
|
||||
Reference in New Issue
Block a user