From 59f9db0f03fdb0eec8a70c82f7ff0812da4e5ef8 Mon Sep 17 00:00:00 2001 From: yoon6yo Date: Fri, 9 Oct 2026 18:49:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=90=EC=A3=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EA=B3=BC=20Slack=20=EA=B0=80=EC=9E=85=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 가입 화면과 계정 설정에서 자주 사용하는 이메일(Slack에 가입한 이메일)을 받는다. - 신청 화면 위에 Slack 가입이 필요하다는 안내를 둔다. - Slack에서 찾지 못한 사람에게는 포털에 들어올 때 이메일을 받는 창을 띄운다. Co-Authored-By: Claude Opus 5.5 --- src/components/SlackMembershipModal.jsx | 100 ++++++++++++++++++ src/locales/en.json | 21 +++- src/locales/ko.json | 21 +++- src/pages/AccountPage.jsx | 35 +++++- src/pages/auth/SignupPage.jsx | 10 +- src/pages/decs-console/user/RequestWizard.jsx | 1 + src/pages/decs-console/user/UserPortalApp.jsx | 2 + src/services/authService.js | 20 ++++ 8 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 src/components/SlackMembershipModal.jsx diff --git a/src/components/SlackMembershipModal.jsx b/src/components/SlackMembershipModal.jsx new file mode 100644 index 0000000..a36fb9b --- /dev/null +++ b/src/components/SlackMembershipModal.jsx @@ -0,0 +1,100 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, Button, FormField, Input, Modal } from "../design-system"; +import { useAuth } from "../hooks/useAuth"; +import { authService } from "../services/authService"; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const DISMISSED_KEY = "slackMembershipModalDismissed"; + +const wasDismissed = () => { + try { + return sessionStorage.getItem(DISMISSED_KEY) === "1"; + } catch { + return false; + } +}; + +const rememberDismissed = () => { + try { + sessionStorage.setItem(DISMISSED_KEY, "1"); + } catch { + // 저장하지 못하면 이번 화면에서만 닫힌다. + } +}; + +/** + * Slack에서 이 사람을 찾지 못했을 때만 뜬다. 지금 신청하면 거절되므로, 신청 전에 Slack에 가입한 이메일을 받아 둔다. + * 닫으면 이번 접속 동안은 다시 뜨지 않고, 확인되면 그 뒤로는 뜨지 않는다. + */ +export default function SlackMembershipModal() { + const { t } = useTranslation(); + const { user, updateUser } = useAuth(); + const [visible, setVisible] = useState(false); + const [contactEmail, setContactEmail] = useState(""); + const [error, setError] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!user || wasDismissed()) return undefined; + let cancelled = false; + authService.getSlackMembership() + .then((status) => { + if (cancelled || status !== "NOT_MEMBER") return; + setContactEmail(user.contactEmail || ""); + setVisible(true); + }) + .catch(() => { + // 확인하지 못하면 띄우지 않는다. 신청할 때 다시 확인한다. + }); + return () => { cancelled = true; }; + // 접속한 사람이 바뀔 때만 다시 확인한다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.userId]); + + const dismiss = () => { + rememberDismissed(); + setVisible(false); + }; + + const save = async () => { + const value = contactEmail.trim(); + if (!value || !EMAIL_PATTERN.test(value)) { + setError(t("auth.contactEmailInvalid")); + return; + } + setSaving(true); + setError(""); + try { + await authService.updateContactEmail(value); + await updateUser(); + if ((await authService.getSlackMembership()) === "NOT_MEMBER") { + setError(t("slackModal.stillNotFound")); + } else { + setVisible(false); + } + } catch (e) { + // 서버가 답한 오류는 원인별 문구를 그대로 쓰고, 연결 실패 등은 기본 문구를 쓴다. + setError(e?.status && e.message ? e.message : t("account.profileSaveFailed")); + } finally { + setSaving(false); + } + }; + + return ( + } + > +
+

{t("slackModal.body")}

+ + { setContactEmail(value); setError(""); }} invalid={!!error} /> + + {t("slackModal.nameNote", { name: user?.name || "" })} +
+
+ ); +} diff --git a/src/locales/en.json b/src/locales/en.json index 163e018..a121690 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -27,6 +27,9 @@ "department": "Department", "studentId": "Student ID", "phone": "Phone", + "contactEmail": "Frequently used email", + "contactEmailHelp": "Enter the email you joined Slack with. If you joined with your school email, keep it the same or leave it blank.", + "contactEmailInvalid": "The email format is not valid.", "verificationCode": "Verification code", "login": "Log in", "signup": "Sign up", @@ -61,7 +64,7 @@ "previous": "Previous", "ubuntuUsername": "Ubuntu username", "domainHelp": "Use a dgu.ac.kr or dongguk.edu email address.", - "slackHelp": "Use the exact same name as your Slack display name to receive notifications.", + "slackHelp": "Use the exact same name as your Slack display name. Otherwise you cannot request a server or receive notifications.", "ubuntuUsernameHelp": "The account name you use to SSH into your containers. Every container you get uses this name, and it cannot be changed after signup." }, "shell": { @@ -154,6 +157,8 @@ }, "wizard": { "title": "GPU request", + "slackTitle": "Join Slack before requesting", + "slackBody": "You must be a member of the Slack workspace to submit a request. Your Slack name must match the name in your profile, and the email you joined Slack with must match your school email or the 'Frequently used email' in account settings.", "roleLeader": "Team leader", "roleMember": "Team member", "teamStep1": "Create a group with 'Create a new shared group' below, then submit this request.", @@ -414,12 +419,20 @@ "deniedOn": "Denied: {{date}}", "adminComment": "Administrator comment" }, + "slackModal": { + "title": "We could not find you on Slack", + "body": "You must be a member of the Slack workspace to request a server. Enter the email you joined Slack with.", + "emailHelp": "If you joined Slack with an address other than your school email ({{email}}), enter that address.", + "nameNote": "Your Slack name (display name or real name) must also exactly match the name in your profile, \"{{name}}\". If you have not joined Slack yet, join first.", + "stillNotFound": "Saved, but we still could not find you on Slack. Check the email and your Slack name again.", + "later": "Later" + }, "account": { "phoneRequired": "Enter your phone number.", - "phoneSaved": "Your phone number was updated.", - "phoneSaveFailed": "Your phone number could not be updated. Please try again shortly.", + "profileSaved": "Saved.", + "profileSaveFailed": "Could not save. Please try again shortly.", "basicTitle": "Basic information", - "basicDesc": "Only the phone number can be changed.", + "basicDesc": "Only the phone number and frequently used email can be changed.", "emailMissing": "No email", "studentIdMissing": "No student ID", "nameMissing": "No name", diff --git a/src/locales/ko.json b/src/locales/ko.json index b70c075..74c09d3 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -27,6 +27,9 @@ "department": "학과", "studentId": "학번", "phone": "전화번호", + "contactEmail": "자주 사용하는 이메일", + "contactEmailHelp": "Slack에 가입한 이메일을 적어 주세요. 학교 이메일로 가입했다면 학교 이메일과 같게 적거나 비워 두면 돼요.", + "contactEmailInvalid": "이메일 형식이 올바르지 않아요.", "verificationCode": "인증번호", "login": "로그인", "signup": "회원가입", @@ -61,7 +64,7 @@ "previous": "이전", "ubuntuUsername": "Ubuntu 사용자명", "domainHelp": "dgu.ac.kr 또는 dongguk.edu 이메일만 사용할 수 있습니다.", - "slackHelp": "Slack 표시 이름과 동일하게 입력해야 알림을 받을 수 있습니다.", + "slackHelp": "Slack 표시 이름과 똑같이 적어 주세요. 다르면 서버를 신청할 수 없고 알림도 받지 못해요.", "ubuntuUsernameHelp": "컨테이너에 SSH로 접속할 때 쓰는 계정 이름입니다. 앞으로 받는 모든 컨테이너에 같은 이름이 쓰이며, 가입 후에는 변경할 수 없습니다." }, "shell": { @@ -151,6 +154,8 @@ }, "wizard": { "title": "GPU 신청", + "slackTitle": "신청 전에 Slack 가입이 필요해요", + "slackBody": "Slack 워크스페이스에 가입되어 있어야 신청할 수 있어요. Slack 이름은 회원 정보의 이름과 같아야 하고, Slack에 가입한 이메일은 학교 이메일이나 계정 설정의 '자주 사용하는 이메일'과 같아야 해요.", "roleLeader": "팀장", "roleMember": "팀원", "teamStep1": "아래 '새 공유 그룹 만들기'로 그룹을 만들고, 이 신청서를 끝까지 제출해요.", @@ -410,12 +415,20 @@ "deniedOn": "거절: {{date}}", "adminComment": "관리자 의견" }, + "slackModal": { + "title": "Slack에서 회원님을 찾지 못했어요", + "body": "서버를 신청하려면 Slack 워크스페이스에 가입되어 있어야 해요. Slack에 가입할 때 쓴 이메일을 적어 주세요.", + "emailHelp": "학교 이메일({{email}})이 아닌 주소로 Slack에 가입했다면 그 주소를 적어 주세요.", + "nameNote": "Slack 이름(표시 이름 또는 실명)도 회원 정보의 이름 \"{{name}}\"과 똑같아야 해요. 아직 Slack에 가입하지 않았다면 먼저 가입해 주세요.", + "stillNotFound": "저장했지만 아직 Slack에서 찾지 못했어요. 이메일과 Slack 이름을 다시 확인해 주세요.", + "later": "나중에" + }, "account": { "phoneRequired": "전화번호를 입력해 주세요.", - "phoneSaved": "전화번호를 바꿨어요.", - "phoneSaveFailed": "전화번호를 바꾸지 못했어요. 잠시 후 다시 시도해 주세요.", + "profileSaved": "저장했어요.", + "profileSaveFailed": "저장하지 못했어요. 잠시 후 다시 시도해 주세요.", "basicTitle": "기본 정보", - "basicDesc": "휴대폰 번호만 바꿀 수 있어요.", + "basicDesc": "휴대폰 번호와 자주 사용하는 이메일만 바꿀 수 있어요.", "emailMissing": "이메일 정보 없음", "studentIdMissing": "학번 정보 없음", "nameMissing": "이름 정보 없음", diff --git a/src/pages/AccountPage.jsx b/src/pages/AccountPage.jsx index dcf24cb..6216e76 100644 --- a/src/pages/AccountPage.jsx +++ b/src/pages/AccountPage.jsx @@ -26,11 +26,14 @@ const ResultAlert = ({ alert, onDismiss }) => ) : null; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const AccountPage = ({ user }) => { const { t, i18n } = useTranslation(); const { updateUser } = useAuth(); const [formData, setFormData] = useState({ phone: "", + contactEmail: "", }); const [errors, setErrors] = useState({}); const [profileLoading, setProfileLoading] = useState(false); @@ -41,6 +44,7 @@ const AccountPage = ({ user }) => { if (user) { setFormData({ phone: user.phone || "", + contactEmail: user.contactEmail || "", }); } }, [user]); @@ -69,6 +73,10 @@ const AccountPage = ({ user }) => { newErrors.phone = phoneFormatError(); } + if (formData.contactEmail.trim() && !EMAIL_PATTERN.test(formData.contactEmail.trim())) { + newErrors.contactEmail = t("auth.contactEmailInvalid"); + } + setErrors(newErrors); return Object.keys(newErrors).length === 0; }; @@ -84,13 +92,18 @@ const AccountPage = ({ user }) => { setProfileAlert(null); try { - await authService.updatePhone(formData.phone); - setProfileAlert({ type: "success", message: t("account.phoneSaved") }); + if (formData.phone !== (user?.phone || "")) { + await authService.updatePhone(formData.phone); + } + if (formData.contactEmail.trim() !== (user?.contactEmail || "")) { + await authService.updateContactEmail(formData.contactEmail.trim()); + } + setProfileAlert({ type: "success", message: t("account.profileSaved") }); await updateUser(); } catch (error) { setProfileAlert({ type: "error", - message: serverMessageOr(error, t("account.phoneSaveFailed")), + message: serverMessageOr(error, t("account.profileSaveFailed")), }); } finally { setProfileLoading(false); @@ -146,6 +159,21 @@ const AccountPage = ({ user }) => { placeholder="010-1234-5678" /> + + handleProfileChange({ target: { name: "contactEmail", value } })} + invalid={!!errors.contactEmail} + placeholder={user?.email || ""} + /> + setProfileAlert(null)} /> @@ -157,6 +185,7 @@ const AccountPage = ({ user }) => { e.preventDefault(); setFormData({ phone: user?.phone || "", + contactEmail: user?.contactEmail || "", }); setErrors({}); }} diff --git a/src/pages/auth/SignupPage.jsx b/src/pages/auth/SignupPage.jsx index 40fad83..4a40642 100644 --- a/src/pages/auth/SignupPage.jsx +++ b/src/pages/auth/SignupPage.jsx @@ -12,10 +12,12 @@ const UBUNTU_USERNAME_FORMAT_ERROR = "소문자로 시작하고 소문자·숫 const UBUNTU_USERNAME_TAKEN_ERROR = "이미 사용 중인 Ubuntu 사용자명입니다."; const UBUNTU_USERNAME_CHECK_FAILED_ERROR = "사용자명 중복 확인에 실패했습니다. 잠시 후 다시 시도해주세요."; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + export default function SignupPage() { const { t, i18n } = useTranslation(); const [step, setStep] = useState(1); - const [form, setForm] = useState({ email: "", verificationCode: "", password: "", confirmPassword: "", name: "", department: "", studentId: "", phone: "", ubuntuUsername: "" }); + const [form, setForm] = useState({ email: "", verificationCode: "", password: "", confirmPassword: "", name: "", department: "", studentId: "", phone: "", ubuntuUsername: "", contactEmail: "" }); const [errors, setErrors] = useState({}); const [loading, setLoading] = useState(false); const [alert, setAlert] = useState(null); @@ -48,14 +50,14 @@ export default function SignupPage() { const usernameError = errors.ubuntuUsername || usernameFormatError || usernameTakenError; const usernameConstraint = usernameStatus === "checking" ? "사용 가능 여부를 확인하는 중이에요." : usernameStatus === "available" ? "사용할 수 있는 이름이에요." : usernameStatus === "failed" ? UBUNTU_USERNAME_CHECK_FAILED_ERROR : t("auth.ubuntuUsernameHelp"); const send = (event) => { event.preventDefault(); if (!/@(dgu\.ac\.kr|dongguk\.edu)$/i.test(form.email)) return setErrors({ email: t("auth.domainHelp") }); run(async () => { const response = await authService.sendEmailVerification(form.email); if (response.status !== 200) throw new Error("인증번호 전송에 실패했습니다."); setCodeSent(true); setAlert({ type: "success", message: "인증번호가 이메일로 전송되었습니다." }); }); }; - const verify = () => { if (!form.verificationCode) return setErrors({ verificationCode: "인증번호를 입력해주세요." }); run(async () => { const response = await authService.verifyEmailCode(form.email, form.verificationCode); if (response.status !== 200) throw new Error("인증번호가 일치하지 않습니다."); setStep(2); }); }; - const signup = (event) => { event.preventDefault(); const required = ["password", "confirmPassword", "name", "department", "studentId", "phone", "ubuntuUsername"]; const next = Object.fromEntries(required.filter((key) => !form[key]).map((key) => [key, `${t(`auth.${key}`)}을(를) 입력해주세요.`])); if (form.password && form.password.length < 8) next.password = "비밀번호는 8자 이상이어야 합니다."; if (form.confirmPassword && form.password !== form.confirmPassword) next.confirmPassword = "비밀번호가 일치하지 않습니다."; if (form.phone && !PHONE_PATTERN.test(form.phone)) next.phone = phoneFormatError(); if (form.ubuntuUsername && !UBUNTU_USERNAME_PATTERN.test(form.ubuntuUsername)) next.ubuntuUsername = UBUNTU_USERNAME_FORMAT_ERROR; else if (usernameTakenError) next.ubuntuUsername = usernameTakenError; setErrors(next); if (Object.keys(next).length) return; run(async () => { const response = await authService.register(form); if (response.status < 200 || response.status >= 300) throw new Error("회원가입에 실패했습니다."); window.location.href = "/login"; }); }; + const verify = () => { if (!form.verificationCode) return setErrors({ verificationCode: "인증번호를 입력해주세요." }); run(async () => { const response = await authService.verifyEmailCode(form.email, form.verificationCode); if (response.status !== 200) throw new Error("인증번호가 일치하지 않습니다."); setForm((current) => ({ ...current, contactEmail: current.contactEmail || current.email })); setStep(2); }); }; + const signup = (event) => { event.preventDefault(); const required = ["password", "confirmPassword", "name", "department", "studentId", "phone", "ubuntuUsername"]; const next = Object.fromEntries(required.filter((key) => !form[key]).map((key) => [key, `${t(`auth.${key}`)}을(를) 입력해주세요.`])); if (form.password && form.password.length < 8) next.password = "비밀번호는 8자 이상이어야 합니다."; if (form.confirmPassword && form.password !== form.confirmPassword) next.confirmPassword = "비밀번호가 일치하지 않습니다."; if (form.phone && !PHONE_PATTERN.test(form.phone)) next.phone = phoneFormatError(); if (form.contactEmail && !EMAIL_PATTERN.test(form.contactEmail)) next.contactEmail = t("auth.contactEmailInvalid"); if (form.ubuntuUsername && !UBUNTU_USERNAME_PATTERN.test(form.ubuntuUsername)) next.ubuntuUsername = UBUNTU_USERNAME_FORMAT_ERROR; else if (usernameTakenError) next.ubuntuUsername = usernameTakenError; setErrors(next); if (Object.keys(next).length) return; run(async () => { const response = await authService.register(form); if (response.status < 200 || response.status >= 300) throw new Error("회원가입에 실패했습니다."); window.location.href = "/login"; }); }; return
동국대학교 로고

{t("auth.signup")}

{t("auth.signupTitle")}

1
2
{alert ?
setAlert(null)}>{alert.message}
: null} - {step === 1 ?
{field("email", "email", { disabled: codeSent, constraintText: t("auth.domainHelp") })}{codeSent ? field("verificationCode") : null}
{codeSent ? : null}
:
{field("email", "email", { disabled: true })}{field("password", "password", { constraintText: "8자 이상. 서버(SSH·Ubuntu) 접속에도 이 비밀번호를 써요." })}{field("confirmPassword", "password")}{field("name", "text", { constraintText: t("auth.slackHelp"), placeholder: "예: 홍길동" })}{field("department", "text", { placeholder: "예: 컴퓨터공학과" })}{field("studentId", "text", { placeholder: "예: 2025112345" })}{field("phone", "tel", { constraintText: phoneHelp(), placeholder: "010-1234-5678" })} change("ubuntuUsername", value)} invalid={!!usernameError} placeholder="소문자·숫자, 3~50자 (SSH 로그인 계정)" />
} + {step === 1 ?
{field("email", "email", { disabled: codeSent, constraintText: t("auth.domainHelp") })}{codeSent ? field("verificationCode") : null}
{codeSent ? : null}
:
{field("email", "email", { disabled: true })}{field("password", "password", { constraintText: "8자 이상. 서버(SSH·Ubuntu) 접속에도 이 비밀번호를 써요." })}{field("confirmPassword", "password")}{field("name", "text", { constraintText: t("auth.slackHelp"), placeholder: "예: 홍길동" })}{field("contactEmail", "email", { constraintText: t("auth.contactEmailHelp") })}{field("department", "text", { placeholder: "예: 컴퓨터공학과" })}{field("studentId", "text", { placeholder: "예: 2025112345" })}{field("phone", "tel", { constraintText: phoneHelp(), placeholder: "010-1234-5678" })} change("ubuntuUsername", value)} invalid={!!usernameError} placeholder="소문자·숫자, 3~50자 (SSH 로그인 계정)" />
}

{t("auth.hasAccount")} {t("auth.login")}

; diff --git a/src/pages/decs-console/user/RequestWizard.jsx b/src/pages/decs-console/user/RequestWizard.jsx index 4152a0e..0d7d816 100644 --- a/src/pages/decs-console/user/RequestWizard.jsx +++ b/src/pages/decs-console/user/RequestWizard.jsx @@ -434,6 +434,7 @@ function RequestWizard({ onCancel, onDone, gpuOptions: gpuOptionsProp, envOption return (
{t("wizard.title")}
+
{t("wizard.slackBody")}
{error ?
{error}
: null} diff --git a/src/pages/decs-console/user/UserPortalApp.jsx b/src/pages/decs-console/user/UserPortalApp.jsx index 6aa83f0..937028c 100644 --- a/src/pages/decs-console/user/UserPortalApp.jsx +++ b/src/pages/decs-console/user/UserPortalApp.jsx @@ -13,6 +13,7 @@ import AccountPage from "../../AccountPage"; import ResourceMonitoringPage from "../../ResourceMonitoringPage"; import donggukLogo from "../../../assets/dongguk_university_logo.svg"; import RoleSwitch from "../../../components/RoleSwitch"; +import SlackMembershipModal from "../../../components/SlackMembershipModal"; import { useTranslation } from "react-i18next"; function UserPortalApp() { @@ -71,6 +72,7 @@ function UserPortalApp() { return (
+ navigate("/user") }} utilities={utilities} diff --git a/src/services/authService.js b/src/services/authService.js index aa54d47..bc3132b 100644 --- a/src/services/authService.js +++ b/src/services/authService.js @@ -74,6 +74,7 @@ export const authService = { studentId: userData.studentId, phone: userData.phone, ubuntuUsername: userData.ubuntuUsername, + contactEmail: userData.contactEmail || null, }); return response; } catch (error) { @@ -127,6 +128,25 @@ export const authService = { } }, + // 지금 신청하면 Slack 가입 확인에 걸리는지 — MEMBER / NOT_MEMBER / UNCHECKED + getSlackMembership: async () => { + requireAccessToken(); + const response = await apiClient.get("/api/users/me/slack-membership"); + return response.data?.data?.status; + }, + + // 자주 사용하는 이메일 변경 (비우면 지운다) + updateContactEmail: async (contactEmail) => { + try { + requireAccessToken(); + const response = await apiClient.patch("/api/users/me/contact-email", { contactEmail }); + return response; + } catch (error) { + if (error.status) throw error; // API 에러는 status 보존 위해 원본 유지 + throw new Error(error.message || "자주 사용하는 이메일 변경에 실패했습니다."); + } + }, + // 우분투 유저네임 등록 (가입 시 못 정한 기존 계정 전용, 1회성 — 이미 등록돼 있으면 실패) registerUbuntuUsername: async (ubuntuUsername) => { try {