API 적용 최초 배포
Deploy feedback demo / deploy (push) Failing after 4m26s

This commit is contained in:
root
2026-09-01 17:12:46 +09:00
parent badf5f2c81
commit aaddfc7bfc
44 changed files with 1331 additions and 867 deletions
@@ -81,6 +81,7 @@ interface SupportAttachmentRecord {
mime_type?: string | null;
file_size?: number | null;
created_at: string;
download_url?: string;
}
interface SupportTicketRecord {
@@ -1081,6 +1082,7 @@ const FeedbackDetailSheet = (props: Props) => {
)
.map(
(attachment) =>
attachment.download_url ??
`/api/support/tickets/${supportTicketId ?? feedbackId}/attachments/${attachment.attachment_id}?workspaceCode=${encodeURIComponent(resolvedWorkspaceCode)}&abcFeedbackId=${feedbackId}`,
)}
names={feedbackAttachments
@@ -1504,6 +1506,7 @@ const FeedbackDetailSheet = (props: Props) => {
<CommentImageGallery
urls={(comment.attachments ?? []).map(
(attachment) =>
attachment.download_url ??
`/api/support/tickets/${supportTicketId ?? feedbackId}/attachments/${attachment.attachment_id}?workspaceCode=${encodeURIComponent(resolvedWorkspaceCode)}&abcFeedbackId=${feedbackId}`,
)}
names={(comment.attachments ?? []).map(
@@ -34,8 +34,10 @@ const TenantGuard: React.FC<IProps> = ({ children }) => {
const router = useRouter();
const { setTenant } = useTenantStore();
const { user } = useUserStore();
const isFeedbackOnlyApp = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true';
const isPreviewRoute =
isFeedbackOnlyApp ||
router.pathname.startsWith('/support') ||
router.pathname === '/ops' ||
router.pathname.startsWith('/admin/issues') ||
+52 -44
View File
@@ -61,7 +61,10 @@ interface Action {
email: string;
password: string;
}) => Promise<void>;
signInWithOAuth: (input: { code: string }) => Promise<void>;
signInWithOAuth: (input: {
code: string;
redirectUri?: string;
}) => Promise<void>;
signOut: () => Promise<void>;
setUser: (jwt?: Jwt) => Promise<void>;
_signIn: (jwt: Jwt) => Promise<void>;
@@ -79,10 +82,10 @@ export const useUserStore = create<State & Action>((set, get) => ({
await get()._signIn(jwt);
},
signInWithOAuth: async ({ code }) => {
signInWithOAuth: async ({ code, redirectUri }) => {
const { data: jwt } = await client.get({
path: '/api/admin/auth/signIn/oauth',
query: { code },
query: { code, redirect_uri: redirectUri },
});
if (!jwt.accessToken || !jwt.refreshToken) {
throw new Error('OAuth login did not return a valid session.');
@@ -90,6 +93,9 @@ export const useUserStore = create<State & Action>((set, get) => ({
await get()._signIn(jwt);
},
signOut: async () => {
if (typeof window !== 'undefined') {
await fetch('/api/auth/sign-out', { method: 'POST' });
}
await cookieStorage.removeItem('jwt');
set({ user: null });
if (typeof window !== 'undefined') {
@@ -108,12 +114,23 @@ export const useUserStore = create<State & Action>((set, get) => ({
if (!sub || !exp || dayjs().isAfter(dayjs.unix(exp))) {
await get().signOut();
} else {
const { data } = await client.get({
path: '/api/admin/users/{id}',
pathParams: { id: parseInt(sub) },
options: { headers: { Authorization: `Bearer ${jwt.accessToken}` } },
const payload = jwtDecode<JwtPayload & {
email?: string;
name?: string | null;
department?: string | null;
phone_number?: string | null;
}>(jwt.accessToken);
set({
user: {
id: Number.parseInt(sub, 10) || 0,
email: payload.email ?? `${sub}@sso.local`,
type: 'GENERAL',
name: payload.name ?? null,
department: payload.department ?? null,
phoneNumber: payload.phone_number ?? null,
signUpMethod: 'OAUTH',
} as User,
});
set({ user: data });
}
},
async _signIn(jwt) {
@@ -178,56 +195,47 @@ export const useUserStore = create<State & Action>((set, get) => ({
router.query.callback_url
: storedCallbackUrl ?? callbackCookieUrl;
const hasWorkspaceManagerRole =
access?.workspaces?.some(
(workspace) =>
workspace.workspace_role === 'PROJECT_MANAGER' ||
workspace.can_manage === true,
) ?? false;
const isRegisteredAdmin =
access?.is_admin === true ||
access?.is_system_admin === true ||
hasWorkspaceManagerRole ||
Boolean(access?.default_admin_path) ||
(!hasSupportAccess && get().user?.type === 'SUPER');
// This deployment is the feedback writer only. Administrator status is
// handled by the existing console, so an administrator must not be sent
// to its dashboard from this web app.
const isSupportCreatePath = (value?: string | null) =>
typeof value === 'string' &&
/^\/support\/[^/]+\/new(?:\?.*)?$/.test(value);
if (isRegisteredAdmin) {
if (access?.default_admin_path) {
await router.push(access.default_admin_path);
} else {
// A manager without a DB project mapping must still enter the
// admin console. The mapped project route is preferred above; the
// main console is the safe fallback and never the end-user form.
await router.push({ pathname: Path.MAIN });
}
} else if (callbackUrl) {
const safeCallbackUrl = isSupportCreatePath(callbackUrl) ? callbackUrl : null;
const defaultSupportCreatePath = isSupportCreatePath(
access?.default_support_create_path,
) ? access?.default_support_create_path : null;
if (process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true') {
if (typeof window !== 'undefined') {
window.sessionStorage.removeItem(OAUTH_CALLBACK_URL_STORAGE_KEY);
window.sessionStorage.removeItem(OAUTH_FORCE_LOGIN_STORAGE_KEY);
document.cookie = 'ufb.oauth.callback-url=; Path=/; Max-Age=0; SameSite=Lax';
}
await router.push(callbackUrl);
} else if (access?.default_support_path) {
await router.push(access.default_support_path);
} else if (access?.workspaces?.[0]?.workspace_code) {
await router.push(
'/support/' +
encodeURIComponent(
access.workspaces[0].workspace_code ??
DEFAULT_SUPPORT_WORKSPACE_CODE,
) +
'/list',
);
} else if (!hasSupportAccess) {
await router.push(
'/support/' +
encodeURIComponent(DEFAULT_SUPPORT_WORKSPACE_CODE) +
'/list',
);
return;
}
if (safeCallbackUrl || defaultSupportCreatePath) {
if (typeof window !== 'undefined') {
window.sessionStorage.removeItem(OAUTH_CALLBACK_URL_STORAGE_KEY);
window.sessionStorage.removeItem(OAUTH_FORCE_LOGIN_STORAGE_KEY);
document.cookie = 'ufb.oauth.callback-url=; Path=/; Max-Age=0; SameSite=Lax';
}
await router.push(safeCallbackUrl ?? defaultSupportCreatePath ?? '');
} else {
const workspaceCode =
hasSupportAccess && access?.workspaces?.[0]?.workspace_code
? access.workspaces[0].workspace_code
: DEFAULT_SUPPORT_WORKSPACE_CODE;
await router.push(
'/support/' +
encodeURIComponent(DEFAULT_SUPPORT_WORKSPACE_CODE) +
encodeURIComponent(workspaceCode) +
'/list',
);
}
@@ -50,7 +50,12 @@ export const useOAuthCallback = () => {
// effects twice during local development, so exchange each code once.
processedCodeRef.current = code;
void signInWithOAuth({ code }).catch((error) => {
const redirectUri =
typeof window !== 'undefined' ?
`${window.location.origin}/api/auth/baron-sso/callback`
: undefined;
void signInWithOAuth({ code, redirectUri }).catch((error) => {
if (error instanceof AxiosError && error.response) {
const message = error.response.data as IFetchError;
toast.error(
@@ -13,127 +13,33 @@
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useEffect } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { Button } from '@ufb/react';
import { useOAIQuery } from '@/shared';
import { useTenantStore } from '@/entities/tenant';
interface IProps {}
const OAUTH_CALLBACK_URL_STORAGE_KEY = 'ufb.oauth.callback-url';
const OAUTH_FORCE_LOGIN_STORAGE_KEY = 'ufb.oauth.force-login';
const SignInWithOAuthButton: React.FC<IProps> = () => {
const { t } = useTranslation();
const router = useRouter();
const { tenant } = useTenantStore();
const callback_url = (router.query.callback_url ?? '') as string;
const force_login =
typeof window !== 'undefined' ?
(router.query.force_login ??
window.sessionStorage.getItem(OAUTH_FORCE_LOGIN_STORAGE_KEY) ??
'')
: '';
const { data } = useOAIQuery({
path: '/api/admin/auth/signIn/oauth/loginURL',
queryOptions: { enabled: tenant?.useOAuth ?? false },
variables: {
callback_url,
force_login: force_login ? 'true' : undefined,
},
});
useEffect(() => {
if (!force_login || !data?.url || typeof window === 'undefined') return;
if (callback_url) {
window.sessionStorage.setItem(
OAUTH_CALLBACK_URL_STORAGE_KEY,
callback_url,
);
} else {
window.sessionStorage.removeItem(OAUTH_CALLBACK_URL_STORAGE_KEY);
}
window.sessionStorage.setItem(OAUTH_FORCE_LOGIN_STORAGE_KEY, 'true');
window.location.assign(data.url);
}, [callback_url, data?.url, force_login]);
if (tenant?.oauthConfig?.loginButtonType === 'GOOGLE') {
return (
<Button
variant="outline"
size="medium"
disabled={!data?.url}
onClick={() => {
if (typeof window !== 'undefined') {
if (callback_url) {
window.sessionStorage.setItem(
OAUTH_CALLBACK_URL_STORAGE_KEY,
callback_url,
);
} else {
window.sessionStorage.removeItem(OAUTH_CALLBACK_URL_STORAGE_KEY);
}
if (force_login) {
window.sessionStorage.setItem(
OAUTH_FORCE_LOGIN_STORAGE_KEY,
'true',
);
} else {
window.sessionStorage.removeItem(OAUTH_FORCE_LOGIN_STORAGE_KEY);
}
window.location.assign(data?.url ?? '');
}
}}
>
<Image
src="/assets/images/google.svg"
alt="Google"
width={24}
height={24}
/>
Google {t('button.sign-in')}
</Button>
);
}
const callbackUrl =
typeof router.query.callback_url === 'string' ? router.query.callback_url : '';
const loginUrl = `/api/auth/baron-sso/login${
callbackUrl ? `?callback_url=${encodeURIComponent(callbackUrl)}` : ''
}`;
return (
<Button
variant="outline"
size="medium"
disabled={!data?.url}
onClick={() => {
if (typeof window !== 'undefined') {
if (callback_url) {
window.sessionStorage.setItem(
OAUTH_CALLBACK_URL_STORAGE_KEY,
callback_url,
);
} else {
window.sessionStorage.removeItem(OAUTH_CALLBACK_URL_STORAGE_KEY);
}
if (force_login) {
window.sessionStorage.setItem(
OAUTH_FORCE_LOGIN_STORAGE_KEY,
'true',
);
} else {
window.sessionStorage.removeItem(OAUTH_FORCE_LOGIN_STORAGE_KEY);
}
window.location.assign(data?.url ?? '');
}
window.location.assign(loginUrl);
}}
>
{tenant?.oauthConfig?.loginButtonName ??
`OAuth 2.0 ${t('button.sign-in')}`}
<Image src="/assets/images/google.svg" alt="" width={20} height={20} />
BARON-SSO {t('button.sign-in')}
</Button>
);
};
@@ -15,23 +15,21 @@
*/
import type { NextApiRequest, NextApiResponse } from 'next';
const handler = (req: NextApiRequest, res: NextApiResponse) => {
const query = new URLSearchParams();
import { completeLogin } from '@/server/local-sso';
for (const [key, value] of Object.entries(req.query)) {
if (typeof value === 'string') {
query.set(key, value);
continue;
}
if (Array.isArray(value) && value.length > 0) {
query.set(key, value[0] ?? '');
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') return res.status(405).send('Method not allowed');
if (typeof req.query.error === 'string') {
const description =
typeof req.query.error_description === 'string'
? req.query.error_description
: req.query.error;
return res.redirect(302, `/auth/sign-in?oauth_error=${encodeURIComponent(description)}`);
}
const suffix = query.toString() ? `?${query.toString()}` : '';
return res.redirect(302, `/auth/oauth-callback${suffix}`);
};
export default handler;
try {
return res.redirect(302, await completeLogin(req, res));
} catch (error) {
const message = error instanceof Error ? error.message : 'BARON-SSO 로그인에 실패했습니다.';
return res.redirect(302, `/auth/sign-in?oauth_error=${encodeURIComponent(message)}`);
}
}
@@ -0,0 +1,16 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { buildLoginUrl, setLoginCookies } from '@/server/local-sso';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') return res.status(405).send('Method not allowed');
try {
const { state, url } = buildLoginUrl(req);
setLoginCookies(req, res, state, req.query.callback_url);
return res.redirect(302, url);
} catch (error) {
return res.status(500).json({
message: error instanceof Error ? error.message : 'BARON-SSO 설정이 올바르지 않습니다.',
});
}
}
+9
View File
@@ -0,0 +1,9 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { clearSession } from '@/server/local-sso';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST' && req.method !== 'GET') return res.status(405).send('Method not allowed');
clearSession(req, res);
return res.status(204).end();
}
+14 -10
View File
@@ -15,16 +15,19 @@
*/
import { createNextApiHandler } from '@/server/api-handler';
import { getSupportAuthHeaders } from '@/server/support-auth';
const supportApiBaseUrl =
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
import { supportExternalUrl } from '@/server/support-external';
const handler = createNextApiHandler({
GET: async (req, res) => {
try {
const query =
req.query.candidates ?
'?candidates=1'
: req.query.management ?
'?management=1'
: '';
const response = await fetch(
supportApiBaseUrl +
(req.query.candidates ? '/api/access/candidates' : req.query.management ? '/api/access/admins' : '/api/access/me'),
supportExternalUrl('/access') + query,
{ headers: getSupportAuthHeaders(req) },
);
const data = (await response.json()) as unknown;
@@ -41,11 +44,12 @@ const handler = createNextApiHandler({
try {
const target =
isManagementRequest ?
supportApiBaseUrl + '/api/access/admins'
: supportApiBaseUrl +
'/api/access/workspaces/' +
supportExternalUrl('/access?management=1')
: supportExternalUrl(
'/access?workspaceCode=' +
workspaceCode +
'/users';
'&users=1',
);
const response = await fetch(target, {
method: 'POST',
headers: {
@@ -66,7 +70,7 @@ const handler = createNextApiHandler({
const assignmentId = req.query.assignmentId as string;
try {
const response = await fetch(
supportApiBaseUrl + '/api/access/admins/' + assignmentId,
supportExternalUrl('/access?management=1&assignmentId=' + assignmentId),
{ headers: getSupportAuthHeaders(req), method: 'DELETE' },
);
const data = (await response.json()) as unknown;
@@ -27,6 +27,7 @@ import {
getSupportPrincipal,
isSupportAdmin,
} from '@/server/support-auth';
import { supportExternalUrl } from '@/server/support-external';
import {
deleteSupportTicketStub,
getSupportTicketDetail,
@@ -36,6 +37,7 @@ import {
const supportApiBaseUrl =
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
const isFeedbackOnly = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true';
const getFeedbackString = (feedback: Record<string, unknown>, key: string) =>
typeof feedback[key] === 'string' ? feedback[key] : '';
@@ -324,6 +326,22 @@ const handler = createNextApiHandler({
query.set('workspaceCode', workspaceCode);
}
if (isFeedbackOnly) {
try {
const suffix = query.toString() ? `?${query.toString()}` : '';
const response = await fetch(
`${supportExternalUrl(`/tickets/${ticketId}`)}${suffix}`,
{ headers: getSupportAuthHeaders(req) },
);
const data = (await response.json()) as unknown;
return res.status(response.status).json(data);
} catch {
return res
.status(503)
.json({ message: '관리 콘솔 API 연결에 실패했습니다.' });
}
}
let isAbcMapped = true;
try {
getSupportAbcTargetConfig(workspaceCode);
@@ -20,9 +20,11 @@ import {
listAbcSupportFeedbackComments,
} from '@/server/support-abc';
import { getSupportAuthHeaders, getSupportPrincipal } from '@/server/support-auth';
import { supportExternalUrl } from '@/server/support-external';
const supportApiBaseUrl =
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
const isFeedbackOnly = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true';
const handler = createNextApiHandler({
GET: async (req, res) => {
@@ -33,6 +35,37 @@ const handler = createNextApiHandler({
const abcFeedbackId =
typeof req.query.abcFeedbackId === 'string' ? req.query.abcFeedbackId : '';
// Feedback-only pages store ticket/comment attachments in the separate
// Secretary API. Do this before the legacy ABC mapping branch; a comment
// attachment has the same ticket_id but is not an ABC attachment.
if (isFeedbackOnly) {
const query = new URLSearchParams();
if (workspaceCode) query.set('workspaceCode', workspaceCode);
if (abcFeedbackId) query.set('abcFeedbackId', abcFeedbackId);
const suffix = query.toString() ? `?${query.toString()}` : '';
try {
const response = await fetch(
`${supportExternalUrl(`/tickets/${ticketId}/attachments/${attachmentId}`)}${suffix}`,
{ headers: getSupportAuthHeaders(req) },
);
const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
const contentDisposition = response.headers.get('content-disposition');
const contentLength = response.headers.get('content-length');
const body = Buffer.from(await response.arrayBuffer());
res.status(response.status);
res.setHeader('Content-Type', contentType);
if (contentDisposition) res.setHeader('Content-Disposition', contentDisposition);
if (contentLength) res.setHeader('Content-Length', contentLength);
res.send(body);
return;
} catch {
res.status(502).json({ message: '관리 콘솔 API 첨부파일 조회에 실패했습니다.' });
return;
}
}
let isAbcMapped = true;
try {
getSupportAbcTargetConfig(workspaceCode);
@@ -105,4 +138,4 @@ const handler = createNextApiHandler({
},
});
export default handler;
export default handler;
@@ -24,9 +24,11 @@ import {
getSupportPrincipal,
applySupportCommentPermissions,
} from '@/server/support-auth';
import { supportExternalUrl } from '@/server/support-external';
const supportApiBaseUrl =
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
const isFeedbackOnly = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true';
const handler = createNextApiHandler({
GET: async (req, res) => {
@@ -47,6 +49,23 @@ const handler = createNextApiHandler({
if (abcFeedbackId) {
query.set('abcFeedbackId', abcFeedbackId);
}
if (isFeedbackOnly) {
try {
const suffix = query.toString() ? `?${query.toString()}` : '';
const response = await fetch(
`${supportExternalUrl(`/tickets/${ticketId}/comments`)}${suffix}`,
{ headers: getSupportAuthHeaders(req) },
);
const data = (await response.json()) as unknown;
return res.status(response.status).json(data);
} catch {
return res
.status(503)
.json({ message: '관리 콘솔 API 연결에 실패했습니다.' });
}
}
let isAbcMapped = true;
try {
getSupportAbcTargetConfig(workspaceCode);
@@ -112,6 +131,29 @@ const handler = createNextApiHandler({
query.set('abcFeedbackId', abcFeedbackId);
}
if (isFeedbackOnly) {
try {
const suffix = query.toString() ? `?${query.toString()}` : '';
const response = await fetch(
`${supportExternalUrl(`/tickets/${ticketId}/comments`)}${suffix}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getSupportAuthHeaders(req),
},
body: JSON.stringify(req.body),
},
);
const data = (await response.json()) as unknown;
return res.status(response.status).json(data);
} catch {
return res
.status(503)
.json({ message: '관리 콘솔 API 연결에 실패했습니다.' });
}
}
let isAbcMapped = true;
try {
getSupportAbcTargetConfig(workspaceCode);
@@ -26,9 +26,11 @@ import {
applySupportCommentPermissions,
isSupportAdmin,
} from '@/server/support-auth';
import { supportExternalUrl } from '@/server/support-external';
const supportApiBaseUrl =
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
const isFeedbackOnly = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true';
const handler = createNextApiHandler({
PUT: async (req, res) => {
@@ -47,6 +49,29 @@ const handler = createNextApiHandler({
if (workspaceCode) query.set('workspaceCode', workspaceCode);
if (abcFeedbackId) query.set('abcFeedbackId', abcFeedbackId);
if (isFeedbackOnly) {
try {
const suffix = query.toString() ? `?${query.toString()}` : '';
const response = await fetch(
`${supportExternalUrl(`/tickets/${ticketId}/comments/${commentId}`)}${suffix}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getSupportAuthHeaders(req),
},
body: JSON.stringify(req.body),
},
);
const data = (await response.json()) as unknown;
return res.status(response.status).json(data);
} catch {
return res
.status(503)
.json({ message: '관리 콘솔 API 연결에 실패했습니다.' });
}
}
try {
let isAbcMapped = true;
try {
@@ -140,6 +165,28 @@ const handler = createNextApiHandler({
if (workspaceCode) query.set('workspaceCode', workspaceCode);
if (abcFeedbackId) query.set('abcFeedbackId', abcFeedbackId);
if (isFeedbackOnly) {
try {
const suffix = query.toString() ? `?${query.toString()}` : '';
const response = await fetch(
`${supportExternalUrl(`/tickets/${ticketId}/comments/${commentId}`)}${suffix}`,
{
method: 'DELETE',
headers: getSupportAuthHeaders(req),
},
);
if (response.status === 204) return res.status(204).end();
const data = (await response.json()) as unknown;
return res.status(response.status).json(data);
} catch {
return res
.status(503)
.json({ message: '관리 콘솔 API 연결에 실패했습니다.' });
}
}
try {
let isAbcMapped = true;
try {
@@ -24,9 +24,11 @@ import {
getSupportAbcTargetConfig,
} from '@/server/support-abc';
import { getSupportAuthHeaders, getSupportPrincipal } from '@/server/support-auth';
import { supportExternalUrl } from '@/server/support-external';
const supportApiBaseUrl =
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
const isFeedbackOnly = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true';
const MAX_UPLOAD_SIZE_BYTES = 30 * 1024 * 1024;
const parseMultipart = (req: NextApiRequest) => {
@@ -76,6 +78,45 @@ const handler = createNextApiHandler({
if (workspaceCode) query.set('workspaceCode', workspaceCode);
if (abcFeedbackId) query.set('abcFeedbackId', abcFeedbackId);
if (isFeedbackOnly) {
try {
const { content, isInternal, files } = await parseMultipart(req);
const formData = new FormData();
formData.append('content', content);
formData.append('is_internal', String(isInternal));
for (const file of files) {
const buffer = await readFile(file.filepath);
formData.append(
'attachments',
new Blob([new Uint8Array(buffer)], {
type: file.mimetype ?? 'application/octet-stream',
}),
file.originalFilename ?? 'comment-attachment.bin',
);
}
const suffix = query.toString() ? `?${query.toString()}` : '';
const response = await fetch(
`${supportExternalUrl(`/tickets/${ticketId}/comments/attachments`)}${suffix}`,
{
method: 'POST',
headers: getSupportAuthHeaders(req),
body: formData,
},
);
const data = (await response.json()) as unknown;
return res.status(response.status).json(data);
} catch (error) {
return res.status(502).json({
message:
error instanceof Error ?
error.message
: '관리 콘솔 API 연결에 실패했습니다.',
});
}
}
let isAbcMapped = true;
try {
getSupportAbcTargetConfig(workspaceCode);
+7 -2
View File
@@ -16,6 +16,7 @@
import { createNextApiHandler } from '@/server/api-handler';
import { getSupportAuthHeaders } from '@/server/support-auth';
import { listSupportWorkspaces } from '@/server/support-stub';
import { supportExternalUrl } from '@/server/support-external';
const supportApiBaseUrl =
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
@@ -23,7 +24,11 @@ const supportApiBaseUrl =
const handler = createNextApiHandler({
GET: async (req, res) => {
try {
const response = await fetch(`${supportApiBaseUrl}/api/workspaces`, {
const endpoint =
process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true' ?
supportExternalUrl('/workspaces')
: `${supportApiBaseUrl}/api/workspaces`;
const response = await fetch(endpoint, {
headers: getSupportAuthHeaders(req),
});
const data = (await response.json()) as unknown;
@@ -35,4 +40,4 @@ const handler = createNextApiHandler({
},
});
export default handler;
export default handler;
@@ -14,102 +14,41 @@
* under the License.
*/
import { createNextApiHandler } from '@/server/api-handler';
import {
getSupportAbcTargetConfig,
listSupportAbcFields,
} from '@/server/support-abc';
import { getSupportAuthHeaders } from '@/server/support-auth';
const supportApiBaseUrl =
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
const toPublicFieldCode = (fieldKey: string) => {
if (fieldKey.toLowerCase() === 'ip') return 'ip_address';
if (fieldKey.toLowerCase().replace(/[_-]/g, '') === 'macaddress') {
return 'mac_address';
}
if (fieldKey.toLowerCase() === 'category') return 'category';
return fieldKey;
};
const toWorkspaceFormTemplate = (
workspaceCode: string,
fields: Awaited<ReturnType<typeof listSupportAbcFields>>,
) => {
const visibleFields = fields
.filter((field) => field.status === 'ACTIVE')
.filter((field) => !['images', 'aiField'].includes(field.format))
.sort((left, right) => {
const leftOrder = left.order ?? Number.MAX_SAFE_INTEGER;
const rightOrder = right.order ?? Number.MAX_SAFE_INTEGER;
return leftOrder - rightOrder || left.id - right.id;
});
return {
workspace_code: workspaceCode,
workspace_name: workspaceCode,
requires_approval: false,
fields: visibleFields.map((field) => ({
field_code:
field.key === 'contents' || field.key === 'message'
? 'description'
: toPublicFieldCode(field.key),
label: field.name,
field_type:
field.key === 'contents' || field.key === 'message'
? 'textarea'
: field.format === 'select' || field.format === 'multiSelect'
? 'select'
: 'text',
required:
['title', 'contents', 'message'].includes(field.key) ||
toPublicFieldCode(field.key) === 'category',
options: field.options ?? [],
})),
};
};
import { supportExternalUrl } from '@/server/support-external';
const handler = createNextApiHandler({
GET: async (req, res) => {
const workspaceCode = req.query.workspaceCode as string;
const workspaceCode =
typeof req.query.workspaceCode === 'string' ? req.query.workspaceCode : '';
try {
getSupportAbcTargetConfig(workspaceCode);
const fields = await listSupportAbcFields(workspaceCode);
return res
.status(200)
.json(toWorkspaceFormTemplate(workspaceCode, fields));
} catch (error) {
if (
error instanceof Error &&
error.message.startsWith('No ABC mapping configured')
) {
// Non-feedback workspaces still use the Secretary form definition.
} else {
return res.status(502).json({
message:
error instanceof Error
? error.message
: 'ABC feedback form fields could not be loaded',
});
}
if (!workspaceCode) {
return res.status(400).json({ message: 'workspaceCode가 필요합니다.' });
}
try {
const response = await fetch(
`${supportApiBaseUrl}/api/workspaces/${encodeURIComponent(workspaceCode)}/form-template`,
{ headers: getSupportAuthHeaders(req) },
supportExternalUrl(
`/workspaces/${encodeURIComponent(workspaceCode)}/form-template`,
),
{
headers: getSupportAuthHeaders(req),
signal: AbortSignal.timeout(10000),
},
);
const data = (await response.json()) as unknown;
const raw = await response.text();
return res.status(response.status).json(data);
try {
return res.status(response.status).json(raw ? JSON.parse(raw) : {});
} catch {
return res.status(response.status).json({ message: raw.slice(0, 1000) });
}
} catch {
return res.status(502).json({
message: 'Support workspace form template could not be loaded',
message: '관리 콘솔 API에서 피드백 양식을 불러오지 못했습니다.',
});
}
},
});
export default handler;
export default handler;
@@ -0,0 +1,188 @@
import { readFile } from 'node:fs/promises';
import type { NextApiRequest } from 'next';
import formidable from 'formidable';
import type {
Fields as FormidableFields,
File as FormidableFile,
Files as FormidableFiles,
} from 'formidable';
import { createNextApiHandler } from '@/server/api-handler';
import { getSupportAuthHeaders } from '@/server/support-auth';
import { supportExternalUrl } from '@/server/support-external';
const MAX_UPLOAD_SIZE_BYTES = 30 * 1024 * 1024;
const MAX_ATTACHMENT_COUNT = 10;
const parseMultipartBody = (req: NextApiRequest) =>
new Promise<{
fields: Record<string, string | string[]>;
files: FormidableFile[];
}>((resolve, reject) => {
const form = formidable({
multiples: true,
maxFiles: MAX_ATTACHMENT_COUNT,
maxFileSize: MAX_UPLOAD_SIZE_BYTES,
maxTotalFileSize: MAX_UPLOAD_SIZE_BYTES,
keepExtensions: true,
});
form.parse(
req,
(
error: Error | null,
fields: FormidableFields,
files: FormidableFiles,
) => {
if (error) {
reject(error);
return;
}
const attachments = Object.entries(files)
.filter(([fieldName]) => fieldName === 'attachments')
.flatMap(([, value]) => (Array.isArray(value) ? value : [value]))
.filter((file): file is FormidableFile => file !== undefined);
resolve({
fields: fields as Record<string, string | string[]>,
files: attachments,
});
},
);
});
const readJsonOrMessage = async (response: Response) => {
const raw = await response.text();
if (!raw.trim()) return {};
try {
return JSON.parse(raw) as unknown;
} catch {
return { message: raw.trim().slice(0, 1000) };
}
};
const isUploadSizeError = (error: unknown) => {
if (!error || typeof error !== 'object') return false;
const candidate = error as {
code?: unknown;
httpCode?: unknown;
message?: unknown;
};
const message = typeof candidate.message === 'string' ? candidate.message : '';
return (
candidate.httpCode === 413 ||
candidate.code === 'ETOOBIG' ||
/(?:file|request).*(?:too large|max.*size)|maxTotalFileSize|maxFileSize/i.test(
message,
)
);
};
const forwardMultipart = async (
workspaceCode: string,
fields: Record<string, string | string[]>,
files: FormidableFile[],
headers: Record<string, string>,
) => {
const formData = new FormData();
for (const [key, value] of Object.entries(fields)) {
if (Array.isArray(value)) {
value.forEach((item) => formData.append(key, item));
} else {
formData.append(key, value);
}
}
for (const file of files) {
const buffer = await readFile(file.filepath);
formData.append(
'attachments',
new Blob([new Uint8Array(buffer)], {
type: file.mimetype ?? 'application/octet-stream',
}),
file.originalFilename ?? 'attachment.bin',
);
}
return fetch(
supportExternalUrl(
`/workspaces/${encodeURIComponent(workspaceCode)}/tickets`,
),
{
method: 'POST',
headers,
body: formData,
signal: AbortSignal.timeout(60000),
},
);
};
const handler = createNextApiHandler({
POST: async (req, res) => {
const workspaceCode =
typeof req.query.workspaceCode === 'string' ? req.query.workspaceCode : '';
const contentType = req.headers['content-type'] ?? '';
if (!workspaceCode) {
return res.status(400).json({ message: 'workspaceCode가 필요합니다.' });
}
try {
let response: Response;
if (contentType.startsWith('multipart/form-data')) {
const { fields, files } = await parseMultipartBody(req);
response = await forwardMultipart(
workspaceCode,
fields,
files,
getSupportAuthHeaders(req),
);
} else {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array),
);
}
response = await fetch(
supportExternalUrl(
`/workspaces/${encodeURIComponent(workspaceCode)}/tickets`,
),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getSupportAuthHeaders(req),
},
body: Buffer.concat(chunks),
signal: AbortSignal.timeout(60000),
},
);
}
return res.status(response.status).json(await readJsonOrMessage(response));
} catch (error) {
return res.status(isUploadSizeError(error) ? 413 : 502).json({
message:
isUploadSizeError(error) ?
'첨부파일은 파일당 최대 30MB, 전체 최대 30MB까지 업로드할 수 있습니다.'
: '관리 콘솔 API로 피드백을 등록하지 못했습니다.',
});
}
},
});
export const config = {
api: {
bodyParser: false,
},
};
export default handler;
@@ -34,6 +34,7 @@ import {
getSupportAuthHeaders,
getSupportPrincipal,
} from '@/server/support-auth';
import { supportExternalUrl } from '@/server/support-external';
import type { SupportPrincipal } from '@/server/support-auth';
import {
createSupportTicketStub,
@@ -45,6 +46,7 @@ import type { SupportTicketRecord } from '@/server/support-types';
const supportApiBaseUrl =
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
const isFeedbackOnly = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true';
const MAX_UPLOAD_SIZE_BYTES = 30 * 1024 * 1024;
const parseJsonBody = async (req: NextApiRequest) => {
@@ -359,6 +361,30 @@ const handler = createNextApiHandler({
const requesterId = req.query.requesterId as string | undefined;
const requesterTenantId = req.query.requesterTenantId as string | undefined;
// The feedback-only writer must read the canonical list from the
// independent Secretary/management API. The legacy branch below reads
// ABC directly and requires a web-container API key, which is not part of
// this deployment by design.
if (isFeedbackOnly) {
try {
const query = new URLSearchParams();
if (requesterId) query.set('requesterId', requesterId);
if (requesterTenantId) query.set('requesterTenantId', requesterTenantId);
const suffix = query.toString() ? `?${query.toString()}` : '';
const response = await fetch(
supportExternalUrl(
`/workspaces/${encodeURIComponent(workspaceCode)}/tickets${suffix}`,
),
{ headers: getSupportAuthHeaders(req) },
);
return res.status(response.status).json(await readJsonOrMessage(response));
} catch {
return res.status(502).json({
message: '관리 콘솔 API에서 피드백 목록을 불러오지 못했습니다.',
});
}
}
let isAbcMapped = true;
try {
getSupportAbcTargetConfig(workspaceCode);
+6 -103
View File
@@ -13,65 +13,18 @@
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState } from 'react';
import type { GetStaticProps } from 'next';
import Link from 'next/link';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslation } from 'next-i18next';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button, toast } from '@ufb/react';
import { AnonymousTemplate, TextInput } from '@/shared';
import type { IFetchError, NextPageWithLayout } from '@/shared/types';
import { useTenantStore } from '@/entities/tenant';
import { useUserStore } from '@/entities/user';
import { AnonymousTemplate } from '@/shared';
import type { NextPageWithLayout } from '@/shared/types';
import { SignInWithOAuthButton } from '@/features/auth/sign-in-with-oauth';
import { AnonymousLayout } from '@/widgets/anonymous-layout';
import serverSideTranslations from '@/server-side-translations';
const signInWithEmailSchema = z.object({
email: z.email(),
password: z.string().min(8),
});
type FormType = z.infer<typeof signInWithEmailSchema>;
const SignInPage: NextPageWithLayout = () => {
const { t } = useTranslation();
const { tenant, refetchTenant } = useTenantStore();
const { signInWithEmail } = useUserStore();
const [loginLoading, setLoginLoading] = useState(false);
const { handleSubmit, register, formState, setError } = useForm<FormType>({
resolver: zodResolver(signInWithEmailSchema),
defaultValues: { email: '', password: '' },
});
useEffect(() => {
if (tenant) return;
void refetchTenant().catch(() => {
// Keep the preview fallback message when the tenant API is unavailable.
});
}, [tenant, refetchTenant]);
const onSubmit = async (data: FormType) => {
try {
setLoginLoading(true);
await signInWithEmail(data);
toast.success(t('v2.toast.success'));
} catch (error) {
const { message } = error as IFetchError;
setError('email', { message: 'invalid email' });
setError('password', { message: 'invalid password' });
toast.error(message);
} finally {
setLoginLoading(false);
}
};
return (
<AnonymousTemplate
@@ -84,60 +37,10 @@ const SignInPage: NextPageWithLayout = () => {
</p>
}
>
{!tenant && (
<div className="rounded-16 border border-[#e4dfd4] bg-[#faf7f0] p-4 text-sm leading-6 text-[#5e5544]">
로컬 미리보기에서는 tenant 설정 API(`/api/admin/tenants`)가 없어 로그인 입력칸이 표시되지 않습니다.
<br />
지원 화면 확인은 `/support/INTRA_BOOK_REQUEST/new`, `/ops`, `/admin/issues` 같은 공개 미리보기 경로를 직접 사용해 주세요.
</div>
)}
{tenant?.useOAuth && <SignInWithOAuthButton />}
{tenant?.useOAuth && tenant.useEmail && (
<div className="flex items-center gap-2">
<div className="border-neutral-tertiary flex-1 border-b-[1px]" />
<span className="text-neutral-tertiary">or With Email</span>
<div className="border-neutral-tertiary flex-1 border-b-[1px]" />
</div>
)}
{tenant?.useEmail && (
<form id="sign-in" onSubmit={handleSubmit(onSubmit)}>
<TextInput
label="Email"
placeholder={t('v2.placeholder.text')}
type="email"
{...register('email')}
error={formState.errors.email?.message}
/>
<TextInput
label="Password"
placeholder={t('v2.placeholder.text')}
type="password"
{...register('password')}
error={formState.errors.password?.message}
/>
</form>
)}
{tenant?.useEmail && (
<div className="flex flex-col gap-4">
<Button
size="medium"
type="submit"
loading={loginLoading}
form="sign-in"
disabled={!formState.isDirty}
>
{t('button.sign-in')}
</Button>
<div className="flex flex-col gap-3">
<Link href="/auth/reset-password" className="text-center underline">
{t('link.reset-password.title')}
</Link>
<Link href="/auth/sign-up" className="text-center underline">
{t('button.sign-up')}
</Link>
</div>
</div>
)}
<SignInWithOAuthButton />
<p className="text-center text-sm text-neutral-secondary">
BARON-SSO EGBIM_DEMO .
</p>
</AnonymousTemplate>
);
};
@@ -30,7 +30,6 @@ import { formatSupportTimestamp } from '@/features/support-portal/lib/format-sup
import { getSupportCategoryLabel } from '@/features/support-portal/lib/support-category';
import SupportPortalShell from '@/features/support-portal/ui/support-portal-shell.ui';
import serverSideTranslations from '@/server-side-translations';
import type { SupportTicketRecord } from '@/server/support-types';
interface SupportTicketCommentRecord {
@@ -839,6 +838,7 @@ const SupportDetailPage: NextPageWithLayout = () => {
<CommentImageGallery
urls={(comment.attachments ?? []).map(
(attachment) =>
attachment.download_url ??
`/api/support/tickets/${ticketId}/attachments/${attachment.attachment_id}?workspaceCode=${encodeURIComponent(workspaceCode)}`,
)}
names={(comment.attachments ?? []).map(
@@ -914,10 +914,15 @@ SupportDetailPage.getLayout = (page: React.ReactNode) => {
return page;
};
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
const workspaceCode =
typeof params?.workspaceCode === 'string' ? params.workspaceCode : 'EGBIM_DEMO';
return {
props: {
...(await serverSideTranslations(locale)),
workspaceCode,
ticketId:
typeof params?.ticketId === 'string' ? params.ticketId : null,
},
};
};
@@ -21,7 +21,7 @@ export const getServerSideProps: GetServerSideProps = ({ params }) => {
return Promise.resolve({
redirect: {
destination: `/support/${workspaceCode}/list`,
destination: `/support/${encodeURIComponent(workspaceCode)}/list`,
permanent: false,
},
});
@@ -29,4 +29,4 @@ export const getServerSideProps: GetServerSideProps = ({ params }) => {
const SupportWorkspaceIndexPage = () => null;
export default SupportWorkspaceIndexPage;
export default SupportWorkspaceIndexPage;
@@ -28,7 +28,6 @@ import {
import SupportPortalShell from '@/features/support-portal/ui/support-portal-shell.ui';
import SupportStatusBadge from '@/features/support-portal/ui/support-status-badge.ui';
import serverSideTranslations from '@/server-side-translations';
import type {
SupportTicketRecord,
WorkspaceSummary,
@@ -506,10 +505,13 @@ SupportListPage.getLayout = (page: React.ReactNode) => {
return page;
};
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
const workspaceCode =
typeof params?.workspaceCode === 'string' ? params.workspaceCode : 'EGBIM_DEMO';
return {
props: {
...(await serverSideTranslations(locale)),
workspaceCode,
},
};
};
@@ -21,6 +21,7 @@ import { useRouter } from 'next/router';
import { toast } from '@ufb/react';
import {
DEFAULT_SUPPORT_WORKSPACE_CODE,
DescriptionTooltip,
fetchWithAuthRefresh,
} from '@/shared';
@@ -28,7 +29,10 @@ import type { NextPageWithLayout } from '@/shared/types';
import SupportPortalShell from '@/features/support-portal/ui/support-portal-shell.ui';
import serverSideTranslations from '@/server-side-translations';
import type { WorkspaceFormTemplateResponse } from '@/server/support-types';
import type {
WorkspaceFormField,
WorkspaceFormTemplateResponse,
} from '@/server/support-types';
interface TicketCreateResponse {
ticket_id: number;
@@ -39,6 +43,14 @@ interface TicketCreateResponse {
message: string;
}
interface ApiErrorResponse {
message?: string;
detail?: string;
error?: {
message?: string;
};
}
interface AttachmentItem {
id: string;
name: string;
@@ -90,6 +102,67 @@ const allowedFieldCodes = [
'mac_address',
];
const demoCompatibilityFields: WorkspaceFormField[] = [
{
field_code: 'category',
label: '구분',
field_type: 'select',
required: true,
options: [
{ id: 1, key: 'ERROR_QNA', name: '오류 문의' },
{ id: 2, key: 'IMPROVEMENT_QNA', name: '개선 문의' },
{ id: 3, key: 'GENERAL_QNA', name: '일반 문의' },
],
},
{
field_code: 'title',
label: '제목',
field_type: 'text',
required: true,
},
{
field_code: 'description',
label: '내용',
field_type: 'textarea',
required: true,
},
{
field_code: 'ip_address',
label: '사용자 IP 주소',
field_type: 'text',
required: false,
},
{
field_code: 'mac_address',
label: 'MAC 주소',
field_type: 'text',
required: false,
},
];
const normalizeDemoTemplate = (
workspaceCode: string,
template: WorkspaceFormTemplateResponse,
) => {
if (workspaceCode !== DEFAULT_SUPPORT_WORKSPACE_CODE) return template;
const fieldsByCode = new Map(
template.fields.map((field) => [field.field_code, field]),
);
const normalizedFields = demoCompatibilityFields.map(
(fallbackField) => fieldsByCode.get(fallbackField.field_code) ?? fallbackField,
);
const knownCodes = new Set(demoCompatibilityFields.map((field) => field.field_code));
return {
...template,
fields: [
...normalizedFields,
...template.fields.filter((field) => !knownCodes.has(field.field_code)),
],
};
};
const fieldHelp: Record<string, string> = {
ip_address:
'Windows: 명령 프롬프트에서 ipconfig를 실행한 뒤 IPv4 주소를 확인하세요.\nmacOS: 시스템 설정 > 네트워크 > 연결된 네트워크 > 세부사항 > TCP/IP에서 확인하세요.',
@@ -97,8 +170,6 @@ const fieldHelp: Record<string, string> = {
'Windows: 명령 프롬프트에서 ipconfig /all을 실행한 뒤 Physical Address를 확인하세요.\nmacOS: 시스템 설정 > 네트워크 > 연결된 네트워크 > 세부사항 > 하드웨어에서 확인하세요.',
};
const TEST_PROJECT_NAME = 'Q&A_Platform';
const SupportNewPage: NextPageWithLayout = () => {
const router = useRouter();
const workspaceCode =
@@ -153,7 +224,10 @@ const SupportNewPage: NextPageWithLayout = () => {
);
}
const loadedTemplate = data as WorkspaceFormTemplateResponse;
const loadedTemplate = normalizeDemoTemplate(
workspaceCode,
data as WorkspaceFormTemplateResponse,
);
setTemplate(loadedTemplate);
setFieldValues(
loadedTemplate.fields.reduce<Record<string, string>>(
@@ -275,7 +349,7 @@ const SupportNewPage: NextPageWithLayout = () => {
}
const response = await fetchWithAuthRefresh(
`/api/support/workspaces/${encodedWorkspaceCode}/tickets`,
`/api/support/workspaces/${encodedWorkspaceCode}/submit`,
{
method: 'POST',
body: formData,
@@ -284,25 +358,26 @@ const SupportNewPage: NextPageWithLayout = () => {
const data = (await response.json()) as
| TicketCreateResponse
| { message?: string };
| ApiErrorResponse;
if (!response.ok) {
const errorResponse = data as ApiErrorResponse;
throw new Error(
'message' in data && data.message ?
data.message
: '티켓 생성 요청에 실패했습니다.',
errorResponse.message ||
errorResponse.detail ||
errorResponse.error?.message ||
'티켓 생성 요청에 실패했습니다.',
);
}
const created = data as TicketCreateResponse;
setCreatedTicketId(created.ticket_id);
setSubmitMessage(
`ticket_id=${created.ticket_id}, status=${created.status_code}, sync=${created.sync_status}`,
`등록이 완료되었습니다. 첨부파일 ${attachments.length}개가 포함되었습니다.`,
);
toast.success('지원 요청이 생성되었습니다.');
await router.push(
`/support/${encodedWorkspaceCode}/list`,
);
await router.push(`/support/${encodedWorkspaceCode}/list`);
return;
} catch (error) {
const message =
error instanceof Error ?
@@ -317,8 +392,11 @@ const SupportNewPage: NextPageWithLayout = () => {
return (
<SupportPortalShell
workspaceCode={workspaceCode || 'Q&A_Platform'}
workspaceName={TEST_PROJECT_NAME}
workspaceCode={workspaceCode || DEFAULT_SUPPORT_WORKSPACE_CODE}
workspaceName={
template?.workspace_name ??
(workspaceCode || DEFAULT_SUPPORT_WORKSPACE_CODE)
}
currentTab="new"
eyebrow="Q&A Write"
title="문의등록"
+223
View File
@@ -0,0 +1,223 @@
import { createHmac, randomBytes } from 'node:crypto';
import type { NextApiRequest, NextApiResponse } from 'next';
const STATE_COOKIE = 'baron_sso_state';
const RETURN_COOKIE = 'baron_sso_return_to';
const SESSION_COOKIE = 'jwt';
const SESSION_MAX_AGE = 60 * 60;
interface OidcUserInfo {
sub?: unknown;
user_id?: unknown;
id?: unknown;
email?: unknown;
name?: unknown;
department?: unknown;
phone_number?: unknown;
tenant_id?: unknown;
tenantId?: unknown;
tenant_ids?: unknown;
tenantIds?: unknown;
}
const asString = (value: unknown) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
const asStringArray = (value: unknown) =>
Array.isArray(value)
? value.filter((item): item is string => Boolean(asString(item)))
: [];
const configured = (name: string) => process.env[name]?.trim() ?? '';
const getIssuer = () => configured('SSO_ISSUER') || 'https://sso.hmac.kr/oidc';
const getEndpoint = (name: string, fallback: string) => configured(name) || fallback;
export const getRedirectUri = (req: NextApiRequest) => {
const configuredUri = configured('SSO_REDIRECT_URI');
if (configuredUri) return configuredUri;
const forwardedProto = req.headers['x-forwarded-proto'];
const protocol =
typeof forwardedProto === 'string' ? forwardedProto.split(',')[0] : 'http';
const host = req.headers.host;
if (!host) throw new Error('요청 호스트를 확인할 수 없습니다.');
return `${protocol}://${host}/api/auth/baron-sso/callback`;
};
const makeCookie = (
name: string,
value: string,
options: { maxAge?: number; httpOnly?: boolean; secure?: boolean } = {},
) => {
const parts = [`${name}=${encodeURIComponent(value)}`, 'Path=/', 'SameSite=Lax'];
if (options.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`);
if (options.httpOnly) parts.push('HttpOnly');
if (options.secure) parts.push('Secure');
return parts.join('; ');
};
const isSecureRequest = (req: NextApiRequest) => {
const forwardedProto = req.headers['x-forwarded-proto'];
return (
(typeof forwardedProto === 'string' && forwardedProto.split(',')[0] === 'https') ||
(process.env.NODE_ENV === 'production' && Boolean(process.env.COOKIE_SECURE))
);
};
const safeReturnTo = (value: unknown) =>
typeof value === 'string' && /^\/support\/[^/]+\/(?:list|new)(?:\?.*)?$/.test(value)
? value
: '/support/EGBIM_DEMO/list';
const requireClientConfig = () => {
const clientId = configured('SSO_CLIENT_ID');
const clientSecret = configured('SSO_CLIENT_SECRET');
if (!clientId || !clientSecret) {
throw new Error('SSO_CLIENT_ID와 SSO_CLIENT_SECRET이 설정되지 않았습니다.');
}
return { clientId, clientSecret };
};
export const buildLoginUrl = (req: NextApiRequest) => {
const { clientId } = requireClientConfig();
const state = randomBytes(32).toString('base64url');
const url = new URL(
getEndpoint('SSO_AUTHORIZATION_ENDPOINT', `${getIssuer()}/oauth2/auth`),
);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', getRedirectUri(req));
url.searchParams.set('scope', configured('SSO_SCOPE') || 'openid profile email');
url.searchParams.set('state', state);
return { state, url: url.toString() };
};
export const setLoginCookies = (
req: NextApiRequest,
res: NextApiResponse,
state: string,
returnTo: unknown,
) => {
const secure = isSecureRequest(req);
res.setHeader('Set-Cookie', [
makeCookie(STATE_COOKIE, state, { maxAge: 600, httpOnly: true, secure }),
makeCookie(RETURN_COOKIE, safeReturnTo(returnTo), { maxAge: 600, httpOnly: true, secure }),
]);
};
const exchangeCode = async (req: NextApiRequest, code: string) => {
const { clientId, clientSecret } = requireClientConfig();
const response = await fetch(
getEndpoint('SSO_TOKEN_ENDPOINT', `${getIssuer()}/oauth2/token`),
{
method: 'POST',
headers: {
// BARON-SSO requires confidential clients to authenticate at the
// token endpoint using HTTP Basic (RFC 6749 client_secret_basic).
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: getRedirectUri(req),
}),
},
);
const data = (await response.json()) as {
access_token?: unknown;
id_token?: unknown;
error_description?: unknown;
};
if (!response.ok || typeof data.access_token !== 'string') {
throw new Error(
asString(data.error_description) ?? 'BARON-SSO 토큰 교환에 실패했습니다.',
);
}
return {
accessToken: data.access_token,
idToken: asString(data.id_token) ?? undefined,
};
};
const getUserInfo = async (accessToken: string): Promise<OidcUserInfo> => {
const response = await fetch(
getEndpoint('SSO_USERINFO_ENDPOINT', `${getIssuer()}/userinfo`),
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
const data = (await response.json()) as OidcUserInfo;
if (!response.ok) throw new Error('BARON-SSO 사용자 정보를 가져오지 못했습니다.');
return data;
};
const signHs256 = (payload: Record<string, unknown>, secret: string) => {
const encode = (value: string) => Buffer.from(value).toString('base64url');
const header = encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = encode(JSON.stringify(payload));
const unsigned = `${header}.${body}`;
const signature = createHmac('sha256', secret).update(unsigned).digest('base64url');
return `${unsigned}.${signature}`;
};
const createSupportSession = (info: OidcUserInfo) => {
const jwtSecret = configured('JWT_SECRET');
if (!jwtSecret) throw new Error('JWT_SECRET이 설정되지 않았습니다.');
const subject = asString(info.sub) ?? asString(info.user_id) ?? asString(info.id);
const tenantId =
configured('SUPPORT_TENANT_ID') ||
asString(info.tenant_id) ||
asString(info.tenantId) ||
asStringArray(info.tenant_ids ?? info.tenantIds)[0] ||
'';
if (!subject || !tenantId) {
throw new Error(
'SSO 사용자 정보에 sub 또는 tenant_id가 없습니다. SUPPORT_TENANT_ID를 확인하세요.',
);
}
const tenantIds = asStringArray(info.tenant_ids ?? info.tenantIds);
if (!tenantIds.includes(tenantId)) tenantIds.unshift(tenantId);
const now = Math.floor(Date.now() / 1000);
return signHs256(
{
sub: subject,
sso_sub: subject,
tenant_id: tenantId,
tenant_ids: tenantIds,
email: asString(info.email),
name: asString(info.name),
department: asString(info.department),
phone_number: asString(info.phone_number),
type: 'GENERAL',
iat: now,
exp: now + SESSION_MAX_AGE,
},
jwtSecret,
);
};
export const completeLogin = async (req: NextApiRequest, res: NextApiResponse) => {
const state = typeof req.query.state === 'string' ? req.query.state : '';
const code = typeof req.query.code === 'string' ? req.query.code : '';
if (!state || !code || !req.cookies[STATE_COOKIE] || state !== req.cookies[STATE_COOKIE]) {
throw new Error('BARON-SSO state가 유효하지 않습니다. 로그인부터 다시 시도하세요.');
}
const tokens = await exchangeCode(req, code);
const userInfo = await getUserInfo(tokens.accessToken);
const sessionToken = createSupportSession(userInfo);
const secure = isSecureRequest(req);
res.setHeader('Set-Cookie', [
makeCookie(SESSION_COOKIE, JSON.stringify({ accessToken: sessionToken, refreshToken: '' }), {
maxAge: SESSION_MAX_AGE,
httpOnly: true,
secure,
}),
makeCookie(STATE_COOKIE, '', { maxAge: 0, httpOnly: true, secure }),
makeCookie(RETURN_COOKIE, '', { maxAge: 0, httpOnly: true, secure }),
]);
return safeReturnTo(req.cookies[RETURN_COOKIE]);
};
export const clearSession = (req: NextApiRequest, res: NextApiResponse) => {
const secure = isSecureRequest(req);
res.setHeader('Set-Cookie', makeCookie(SESSION_COOKIE, '', { maxAge: 0, httpOnly: true, secure }));
};
+3 -4
View File
@@ -15,10 +15,9 @@
*/
import type { NextApiRequest } from 'next';
type StoredJwt = { accessToken?: string };
import { supportExternalUrl } from './support-external';
const supportApiBaseUrl =
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
type StoredJwt = { accessToken?: string };
export interface SupportPrincipal {
user_id: string;
@@ -88,7 +87,7 @@ export const getSupportPrincipal = async (
if (!headers.Authorization) return null;
try {
const response = await fetch(`${supportApiBaseUrl}/api/access/me`, {
const response = await fetch(supportExternalUrl('/access'), {
headers,
});
if (!response.ok) return null;
+20
View File
@@ -0,0 +1,20 @@
/**
* URL builder for the existing management console's support API.
*
* The console exposes Secretary through `/api/support`. A local development
* environment may instead provide the Secretary service directly at a base
* URL such as `http://127.0.0.1:8010`; both forms are supported here.
*/
const configuredBaseUrl =
process.env.SUPPORT_CONSOLE_API_BASE_URL ??
process.env.SUPPORT_API_BASE_URL ??
'https://feedback.hmac.kr/api/support';
export const supportExternalUrl = (path: string) => {
const baseUrl = configuredBaseUrl.replace(/\/$/, '');
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return baseUrl.endsWith('/api/support')
? `${baseUrl}${normalizedPath}`
: `${baseUrl}/api${normalizedPath}`;
};
@@ -22,21 +22,15 @@ export const supportWorkspaceChannelMap: Record<
fieldKeys: string[];
}
> = {
EGBIM: {
projectId: '1',
channelId: '1',
apiKey: '',
fieldKeys: ['title', 'contents'],
},
'Q&A_Platform': {
projectId: '1',
channelId: '1',
EGBIM_DEMO: {
projectId: '8',
channelId: '9',
apiKey: '',
fieldKeys: ['title', 'contents'],
},
};
export const DEFAULT_SUPPORT_WORKSPACE_CODE = 'EGBIM';
export const DEFAULT_SUPPORT_WORKSPACE_CODE = 'EGBIM_DEMO';
export const getSupportWorkspaceCodeByProjectChannel = (
projectId: number | string,
@@ -15,8 +15,6 @@
*/
import { Path } from '@/shared/constants';
import { env } from '@/env';
import type { Jwt } from '../types/jwt.type';
import cookieStorage from './cookie-storage';
let refreshPromise: Promise<boolean> | null = null;
@@ -28,32 +26,14 @@ const refreshAccessToken = async (): Promise<boolean> => {
return false;
}
const refreshUrl = new URL(
`${env.NEXT_PUBLIC_API_BASE_URL}/api/admin/auth/refresh`,
);
refreshUrl.searchParams.set('_refresh', Date.now().toString());
const response = await fetch(refreshUrl, {
cache: 'no-store',
headers: {
Authorization: `Bearer ${currentJwt.refreshToken}`,
'Cache-Control': 'no-cache',
},
});
if (!response.ok) {
await cookieStorage.removeItem('jwt');
return false;
// The feedback-only app has no management-console refresh endpoint. Its
// server-issued session is intentionally short-lived; expire it locally
// and require a fresh BARON-SSO login instead of contacting the console.
if (typeof window !== 'undefined') {
await fetch('/api/auth/sign-out', { method: 'POST' });
}
const nextJwt = (await response.json()) as Jwt;
if (!nextJwt.accessToken || !nextJwt.refreshToken) {
await cookieStorage.removeItem('jwt');
return false;
}
await cookieStorage.setItem('jwt', nextJwt);
return true;
await cookieStorage.removeItem('jwt');
return false;
};
const tryRefreshAccessToken = () => {
+7 -1
View File
@@ -105,6 +105,7 @@ export interface paths {
query?: {
callback_url?: string;
force_login?: string;
redirect_uri?: string;
};
header?: never;
path?: never;
@@ -139,7 +140,10 @@ export interface paths {
};
'/api/admin/auth/signIn/oauth': {
parameters: {
query?: never;
query?: {
code?: string;
redirect_uri?: string;
};
header?: never;
path?: never;
cookie?: never;
@@ -2668,6 +2672,7 @@ export interface operations {
query?: {
callback_url?: string;
force_login?: string;
redirect_uri?: string;
};
header?: never;
path?: never;
@@ -2710,6 +2715,7 @@ export interface operations {
parameters: {
query?: {
code?: unknown;
redirect_uri?: string;
};
header?: never;
path?: never;