forked from baron/baron-sso
Merge pull request 'feature/password-reset' (#492) from feature/password-reset into dev
Reviewed-on: baron/baron-sso#492
This commit is contained in:
@@ -515,7 +515,7 @@ jobs:
|
|||||||
adminfront-tests:
|
adminfront-tests:
|
||||||
needs: lint
|
needs: lint
|
||||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
|
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
|
||||||
runs-on: playwright
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -594,7 +594,7 @@ jobs:
|
|||||||
devfront-tests:
|
devfront-tests:
|
||||||
needs: lint
|
needs: lint
|
||||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
|
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
|
||||||
runs-on: playwright
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Building2,
|
Building2,
|
||||||
Copy,
|
Copy,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
Key,
|
Key,
|
||||||
Loader2,
|
Loader2,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -32,16 +34,24 @@ import {
|
|||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Label } from "../../components/ui/label";
|
import { Label } from "../../components/ui/label";
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "../../components/ui/tabs";
|
||||||
import { toast } from "../../components/ui/use-toast";
|
import { toast } from "../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
type UserUpdateRequest,
|
type UserUpdateRequest,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
fetchPasswordPolicy,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
fetchUser,
|
fetchUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
|
import type { PasswordPolicyResponse } from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { generateSecurePassword } from "../../lib/utils";
|
import { generateSecurePassword } from "../../lib/utils";
|
||||||
|
|
||||||
@@ -58,6 +68,116 @@ type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
|||||||
metadata: Record<string, Record<string, string | number | boolean>>;
|
metadata: Record<string, Record<string, string | number | boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PasswordResetMode = "generated" | "manual";
|
||||||
|
|
||||||
|
const PASSWORD_RESET_MIN_LENGTH = 12;
|
||||||
|
|
||||||
|
function buildPasswordPolicyDescription(policy?: PasswordPolicyResponse) {
|
||||||
|
const minLength = policy?.minLength ?? PASSWORD_RESET_MIN_LENGTH;
|
||||||
|
const minTypes = policy?.minCharacterTypes ?? 0;
|
||||||
|
const requiresLower = policy?.lowercase ?? true;
|
||||||
|
const requiresUpper = policy?.uppercase ?? false;
|
||||||
|
const requiresNumber = policy?.number ?? true;
|
||||||
|
const requiresSymbol = policy?.nonAlphanumeric ?? true;
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
t("msg.userfront.signup.policy.min_length", "최소 {{count}}자 이상", {
|
||||||
|
count: String(minLength),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (minTypes > 0) {
|
||||||
|
parts.push(
|
||||||
|
t(
|
||||||
|
"msg.userfront.signup.policy.min_types",
|
||||||
|
"영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상",
|
||||||
|
{ count: String(minTypes) },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (requiresLower) {
|
||||||
|
parts.push(t("msg.userfront.signup.policy.lowercase", "소문자"));
|
||||||
|
}
|
||||||
|
if (requiresUpper) {
|
||||||
|
parts.push(t("msg.userfront.signup.policy.uppercase", "대문자"));
|
||||||
|
}
|
||||||
|
if (requiresNumber) {
|
||||||
|
parts.push(t("msg.userfront.signup.policy.number", "숫자"));
|
||||||
|
}
|
||||||
|
if (requiresSymbol) {
|
||||||
|
parts.push(t("msg.userfront.signup.policy.symbol", "특수문자"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateManualPassword(
|
||||||
|
password: string,
|
||||||
|
policy?: PasswordPolicyResponse,
|
||||||
|
) {
|
||||||
|
if (password.trim().length === 0) {
|
||||||
|
return t(
|
||||||
|
"msg.admin.users.detail.password_manual_required",
|
||||||
|
"비밀번호를 입력해 주세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const minLength = policy?.minLength ?? PASSWORD_RESET_MIN_LENGTH;
|
||||||
|
if (password.length < minLength) {
|
||||||
|
return t(
|
||||||
|
"msg.userfront.reset.error.min_length",
|
||||||
|
"비밀번호는 최소 {{count}}자 이상이어야 합니다.",
|
||||||
|
{ count: String(minLength) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLower = /[a-z]/.test(password);
|
||||||
|
const hasUpper = /[A-Z]/.test(password);
|
||||||
|
const hasNumber = /[0-9]/.test(password);
|
||||||
|
const hasSymbol = /[\W_]/.test(password);
|
||||||
|
let typeCount = 0;
|
||||||
|
if (hasLower) typeCount++;
|
||||||
|
if (hasUpper) typeCount++;
|
||||||
|
if (hasNumber) typeCount++;
|
||||||
|
if (hasSymbol) typeCount++;
|
||||||
|
|
||||||
|
const minTypes = policy?.minCharacterTypes ?? 0;
|
||||||
|
if (minTypes > 0 && typeCount < minTypes) {
|
||||||
|
return t(
|
||||||
|
"msg.userfront.reset.error.min_types",
|
||||||
|
"비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.",
|
||||||
|
{ count: String(minTypes) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((policy?.lowercase ?? true) && !hasLower) {
|
||||||
|
return t(
|
||||||
|
"msg.userfront.reset.error.lowercase",
|
||||||
|
"최소 1개 이상의 소문자를 포함해야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((policy?.uppercase ?? false) && !hasUpper) {
|
||||||
|
return t(
|
||||||
|
"msg.userfront.reset.error.uppercase",
|
||||||
|
"최소 1개 이상의 대문자를 포함해야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((policy?.number ?? true) && !hasNumber) {
|
||||||
|
return t(
|
||||||
|
"msg.userfront.reset.error.number",
|
||||||
|
"최소 1개 이상의 숫자를 포함해야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((policy?.nonAlphanumeric ?? true) && !hasSymbol) {
|
||||||
|
return t(
|
||||||
|
"msg.userfront.reset.error.symbol",
|
||||||
|
"최소 1개 이상의 특수문자를 포함해야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function TenantMetadataFields({
|
function TenantMetadataFields({
|
||||||
tenant,
|
tenant,
|
||||||
schema,
|
schema,
|
||||||
@@ -166,6 +286,15 @@ function UserDetailPage() {
|
|||||||
const [generatedPassword, setGeneratedPassword] = React.useState<
|
const [generatedPassword, setGeneratedPassword] = React.useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [passwordResetMode, setPasswordResetMode] =
|
||||||
|
React.useState<PasswordResetMode>("generated");
|
||||||
|
const [manualPassword, setManualPassword] = React.useState("");
|
||||||
|
const [manualPasswordConfirm, setManualPasswordConfirm] = React.useState("");
|
||||||
|
const [isManualPasswordVisible, setIsManualPasswordVisible] =
|
||||||
|
React.useState(false);
|
||||||
|
const [passwordResetError, setPasswordResetError] = React.useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const { data: profile } = useQuery({
|
const { data: profile } = useQuery({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
@@ -187,6 +316,12 @@ function UserDetailPage() {
|
|||||||
queryFn: () => fetchTenants(100, 0),
|
queryFn: () => fetchTenants(100, 0),
|
||||||
});
|
});
|
||||||
const tenants = tenantsData?.items ?? [];
|
const tenants = tenantsData?.items ?? [];
|
||||||
|
const { data: passwordPolicy, isLoading: isPasswordPolicyLoading } = useQuery(
|
||||||
|
{
|
||||||
|
queryKey: ["password-policy"],
|
||||||
|
queryFn: fetchPasswordPolicy,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -211,11 +346,13 @@ function UserDetailPage() {
|
|||||||
|
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||||
|
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
||||||
|
|
||||||
const resetPasswordMutation = useMutation({
|
const resetPasswordMutation = useMutation({
|
||||||
mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
|
mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
|
||||||
onSuccess: (_, newPass) => {
|
onSuccess: (_, newPass) => {
|
||||||
setGeneratedPassword(newPass);
|
setGeneratedPassword(newPass);
|
||||||
|
setPasswordResetError(null);
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t(
|
||||||
"msg.admin.users.detail.password_generated",
|
"msg.admin.users.detail.password_generated",
|
||||||
@@ -224,20 +361,67 @@ function UserDetailPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (err: AxiosError<{ error?: string }>) => {
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
toast.error(
|
const message =
|
||||||
err.response?.data?.error ||
|
err.response?.data?.error ||
|
||||||
t("msg.admin.users.detail.update_error", "수정에 실패했습니다."),
|
t("msg.admin.users.detail.update_error", "수정에 실패했습니다.");
|
||||||
);
|
setPasswordResetError(message);
|
||||||
|
toast.error(message);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleGeneratePassword = () => {
|
const handleOpenPasswordReset = () => {
|
||||||
|
if (isSelf) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsPasswordResetOpen(true);
|
setIsPasswordResetOpen(true);
|
||||||
setGeneratedPassword(null);
|
setGeneratedPassword(null);
|
||||||
|
setPasswordResetMode("generated");
|
||||||
|
setManualPassword("");
|
||||||
|
setManualPasswordConfirm("");
|
||||||
|
setIsManualPasswordVisible(false);
|
||||||
|
setPasswordResetError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmGeneratePassword = () => {
|
const handleClosePasswordReset = () => {
|
||||||
const newPass = generateSecurePassword();
|
setIsPasswordResetOpen(false);
|
||||||
|
setGeneratedPassword(null);
|
||||||
|
setPasswordResetMode("generated");
|
||||||
|
setManualPassword("");
|
||||||
|
setManualPasswordConfirm("");
|
||||||
|
setIsManualPasswordVisible(false);
|
||||||
|
setPasswordResetError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPasswordReset = () => {
|
||||||
|
if (isSelf) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newPass = manualPassword;
|
||||||
|
|
||||||
|
if (passwordResetMode === "manual") {
|
||||||
|
const validationError = validateManualPassword(
|
||||||
|
manualPassword,
|
||||||
|
passwordPolicy,
|
||||||
|
);
|
||||||
|
if (validationError) {
|
||||||
|
setPasswordResetError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (manualPassword !== manualPasswordConfirm) {
|
||||||
|
setPasswordResetError(
|
||||||
|
t(
|
||||||
|
"msg.userfront.reset.error.mismatch",
|
||||||
|
"비밀번호가 일치하지 않습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newPass = generateSecurePassword();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPasswordResetError(null);
|
||||||
resetPasswordMutation.mutate(newPass);
|
resetPasswordMutation.mutate(newPass);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -717,7 +901,7 @@ function UserDetailPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/20">
|
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{t(
|
{t(
|
||||||
@@ -726,44 +910,205 @@ function UserDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
사용자의 비밀번호를 강제로 재설정하고 새 비밀번호를
|
{t(
|
||||||
생성합니다.
|
"msg.admin.users.detail.reset_password_help",
|
||||||
|
"사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={handleGeneratePassword}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleOpenPasswordReset}
|
||||||
|
disabled={isSelf}
|
||||||
|
>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
{t("ui.admin.users.detail.reset_password", "초기화 및 생성")}
|
{t("ui.admin.users.detail.reset_password", "초기화 및 설정")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPasswordResetOpen && !generatedPassword && (
|
{isSelf && (
|
||||||
<div className="p-4 border rounded-lg bg-destructive/5 space-y-4">
|
<div className="rounded-lg border px-4 py-3">
|
||||||
<p className="text-sm">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.users.detail.reset_password_confirm",
|
"msg.admin.users.detail.self_password_reset_blocked",
|
||||||
"정말로 이 사용자의 비밀번호를 초기화하시겠습니까? 기존 비밀번호로는 즉시 로그인할 수 없게 됩니다.",
|
"본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPasswordResetOpen && !generatedPassword && !isSelf && (
|
||||||
|
<div className="space-y-4 rounded-lg border px-4 py-4">
|
||||||
|
<Tabs
|
||||||
|
value={passwordResetMode}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setPasswordResetMode(value as PasswordResetMode);
|
||||||
|
setPasswordResetError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-2 rounded-lg bg-muted p-1">
|
||||||
|
<TabsTrigger
|
||||||
|
value="generated"
|
||||||
|
className="bg-slate-200 font-normal text-foreground data-[state=inactive]:bg-slate-200 data-[state=active]:bg-background data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.detail.password_mode_generated",
|
||||||
|
"자동 생성",
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="manual"
|
||||||
|
className="bg-slate-200 font-normal text-foreground data-[state=inactive]:bg-slate-200 data-[state=active]:bg-background data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.detail.password_mode_manual",
|
||||||
|
"수동 입력",
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="generated" className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.users.detail.password_generated_help",
|
||||||
|
"보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="manual" className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isPasswordPolicyLoading
|
||||||
|
? t(
|
||||||
|
"msg.userfront.signup.policy.loading",
|
||||||
|
"비밀번호 정책을 불러오는 중입니다...",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"msg.userfront.signup.policy.summary",
|
||||||
|
"보안 정책: {{rules}}",
|
||||||
|
{
|
||||||
|
rules:
|
||||||
|
buildPasswordPolicyDescription(
|
||||||
|
passwordPolicy,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
id="manualPassword"
|
||||||
|
type={isManualPasswordVisible ? "text" : "password"}
|
||||||
|
value={manualPassword}
|
||||||
|
placeholder=" "
|
||||||
|
className="peer pr-12 pt-5"
|
||||||
|
onChange={(event) => {
|
||||||
|
setManualPassword(event.target.value);
|
||||||
|
if (passwordResetError) {
|
||||||
|
setPasswordResetError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="manualPassword"
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 bg-background px-1 text-sm text-muted-foreground transition-all peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-sm peer-focus:top-0 peer-focus:translate-y-[-50%] peer-focus:text-xs peer-[&:not(:placeholder-shown)]:top-0 peer-[&:not(:placeholder-shown)]:translate-y-[-50%] peer-[&:not(:placeholder-shown)]:text-xs"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"ui.userfront.reset.new_password",
|
||||||
|
"새 비밀번호",
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2"
|
||||||
|
onClick={() =>
|
||||||
|
setIsManualPasswordVisible((prev) => !prev)
|
||||||
|
}
|
||||||
|
aria-label={t(
|
||||||
|
"ui.admin.users.detail.toggle_password_visibility",
|
||||||
|
"비밀번호 표시 전환",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isManualPasswordVisible ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
id="manualPasswordConfirm"
|
||||||
|
type={isManualPasswordVisible ? "text" : "password"}
|
||||||
|
value={manualPasswordConfirm}
|
||||||
|
placeholder=" "
|
||||||
|
className="peer pr-12 pt-5"
|
||||||
|
onChange={(event) => {
|
||||||
|
setManualPasswordConfirm(event.target.value);
|
||||||
|
if (passwordResetError) {
|
||||||
|
setPasswordResetError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="manualPasswordConfirm"
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 bg-background px-1 text-sm text-muted-foreground transition-all peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-sm peer-focus:top-0 peer-focus:translate-y-[-50%] peer-focus:text-xs peer-[&:not(:placeholder-shown)]:top-0 peer-[&:not(:placeholder-shown)]:translate-y-[-50%] peer-[&:not(:placeholder-shown)]:text-xs"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"ui.userfront.reset.confirm_password",
|
||||||
|
"새 비밀번호 확인",
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2"
|
||||||
|
onClick={() =>
|
||||||
|
setIsManualPasswordVisible((prev) => !prev)
|
||||||
|
}
|
||||||
|
aria-label={t(
|
||||||
|
"ui.admin.users.detail.toggle_password_visibility",
|
||||||
|
"비밀번호 표시 전환",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isManualPasswordVisible ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
{passwordResetError && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{passwordResetError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsPasswordResetOpen(false)}
|
onClick={handleClosePasswordReset}
|
||||||
>
|
>
|
||||||
{t("ui.common.cancel", "취소")}
|
{t("ui.common.cancel", "취소")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={confirmGeneratePassword}
|
onClick={confirmPasswordReset}
|
||||||
disabled={resetPasswordMutation.isPending}
|
disabled={resetPasswordMutation.isPending}
|
||||||
>
|
>
|
||||||
{resetPasswordMutation.isPending && (
|
{resetPasswordMutation.isPending && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.users.detail.reset_password",
|
"ui.admin.users.detail.reset_password_apply",
|
||||||
"초기화 및 생성",
|
"비밀번호 적용",
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -774,7 +1119,10 @@ function UserDetailPage() {
|
|||||||
<div className="p-4 border border-dashed rounded-lg bg-yellow-500/5 flex flex-wrap items-center justify-between gap-4">
|
<div className="p-4 border border-dashed rounded-lg bg-yellow-500/5 flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs font-bold text-yellow-600 uppercase tracking-wider">
|
<p className="text-xs font-bold text-yellow-600 uppercase tracking-wider">
|
||||||
Generated Password
|
{t(
|
||||||
|
"ui.admin.users.detail.password_result_title",
|
||||||
|
"Reset Password",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-lg font-bold">
|
<p className="font-mono text-lg font-bold">
|
||||||
{generatedPassword}
|
{generatedPassword}
|
||||||
|
|||||||
@@ -499,6 +499,22 @@ export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PasswordPolicyResponse = {
|
||||||
|
minLength?: number;
|
||||||
|
lowercase?: boolean;
|
||||||
|
uppercase?: boolean;
|
||||||
|
number?: boolean;
|
||||||
|
nonAlphanumeric?: boolean;
|
||||||
|
minCharacterTypes?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchPasswordPolicy() {
|
||||||
|
const { data } = await apiClient.get<PasswordPolicyResponse>(
|
||||||
|
"/v1/auth/password/policy",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteUser(userId: string) {
|
export async function deleteUser(userId: string) {
|
||||||
await apiClient.delete(`/v1/admin/users/${userId}`);
|
await apiClient.delete(`/v1/admin/users/${userId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,6 +288,12 @@ name_required = "Name Required"
|
|||||||
|
|
||||||
[msg.admin.users.detail.security]
|
[msg.admin.users.detail.security]
|
||||||
password_hint = "Password Hint"
|
password_hint = "Password Hint"
|
||||||
|
password_generated_help = "Generate a temporary password that meets the security policy and apply it immediately."
|
||||||
|
password_manual_help = "Enter and apply a password with at least {{count}} characters."
|
||||||
|
password_manual_min_length = "Password must be at least {{count}} characters long."
|
||||||
|
password_manual_required = "Please enter a password."
|
||||||
|
reset_password_help = "Force-reset the user's password and apply either an auto-generated password or a manually entered one."
|
||||||
|
self_password_reset_blocked = "Please change your own password from the UserFront settings page."
|
||||||
|
|
||||||
[msg.admin.users.list]
|
[msg.admin.users.list]
|
||||||
delete_confirm = "Delete Confirm"
|
delete_confirm = "Delete Confirm"
|
||||||
@@ -1094,6 +1100,16 @@ password = "Password"
|
|||||||
password_placeholder = "Password Placeholder"
|
password_placeholder = "Password Placeholder"
|
||||||
title = "Security Settings"
|
title = "Security Settings"
|
||||||
|
|
||||||
|
[ui.admin.users.detail]
|
||||||
|
manual_password = "New Password"
|
||||||
|
manual_password_placeholder = "Enter a new password"
|
||||||
|
password_mode_generated = "Auto Generate"
|
||||||
|
password_mode_manual = "Manual Input"
|
||||||
|
password_result_title = "Reset Password"
|
||||||
|
reset_password = "Reset & Set"
|
||||||
|
reset_password_apply = "Apply Password"
|
||||||
|
toggle_password_visibility = "Toggle password visibility"
|
||||||
|
|
||||||
[ui.admin.users.detail.tenants_section]
|
[ui.admin.users.detail.tenants_section]
|
||||||
additional = "Additional Affiliated/Manageable Tenants"
|
additional = "Additional Affiliated/Manageable Tenants"
|
||||||
primary = "Representative Affiliated Tenant"
|
primary = "Representative Affiliated Tenant"
|
||||||
|
|||||||
@@ -288,6 +288,12 @@ name_required = "이름은 필수입니다."
|
|||||||
|
|
||||||
[msg.admin.users.detail.security]
|
[msg.admin.users.detail.security]
|
||||||
password_hint = "비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다."
|
password_hint = "비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다."
|
||||||
|
password_generated_help = "보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다."
|
||||||
|
password_manual_help = "최소 {{count}}자 이상의 비밀번호를 직접 입력해 적용합니다."
|
||||||
|
password_manual_min_length = "비밀번호는 최소 {{count}}자 이상이어야 합니다."
|
||||||
|
password_manual_required = "비밀번호를 입력해 주세요."
|
||||||
|
reset_password_help = "사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다."
|
||||||
|
self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요."
|
||||||
|
|
||||||
[msg.admin.users.list]
|
[msg.admin.users.list]
|
||||||
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
|
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
|
||||||
@@ -1094,6 +1100,16 @@ password = "비밀번호 변경"
|
|||||||
password_placeholder = "변경할 경우에만 입력"
|
password_placeholder = "변경할 경우에만 입력"
|
||||||
title = "보안 설정"
|
title = "보안 설정"
|
||||||
|
|
||||||
|
[ui.admin.users.detail]
|
||||||
|
manual_password = "새 비밀번호"
|
||||||
|
manual_password_placeholder = "새 비밀번호를 입력하세요"
|
||||||
|
password_mode_generated = "자동 생성"
|
||||||
|
password_mode_manual = "수동 입력"
|
||||||
|
password_result_title = "Reset Password"
|
||||||
|
reset_password = "초기화 및 설정"
|
||||||
|
reset_password_apply = "비밀번호 적용"
|
||||||
|
toggle_password_visibility = "비밀번호 표시 전환"
|
||||||
|
|
||||||
[ui.admin.users.detail.tenants_section]
|
[ui.admin.users.detail.tenants_section]
|
||||||
additional = "추가 소속/관리 테넌트"
|
additional = "추가 소속/관리 테넌트"
|
||||||
primary = "대표 소속 테넌트"
|
primary = "대표 소속 테넌트"
|
||||||
|
|||||||
@@ -288,6 +288,12 @@ name_required = ""
|
|||||||
|
|
||||||
[msg.admin.users.detail.security]
|
[msg.admin.users.detail.security]
|
||||||
password_hint = ""
|
password_hint = ""
|
||||||
|
password_generated_help = ""
|
||||||
|
password_manual_help = ""
|
||||||
|
password_manual_min_length = ""
|
||||||
|
password_manual_required = ""
|
||||||
|
reset_password_help = ""
|
||||||
|
self_password_reset_blocked = ""
|
||||||
|
|
||||||
[msg.admin.users.list]
|
[msg.admin.users.list]
|
||||||
delete_confirm = ""
|
delete_confirm = ""
|
||||||
@@ -1094,6 +1100,16 @@ password = ""
|
|||||||
password_placeholder = ""
|
password_placeholder = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
|
[ui.admin.users.detail]
|
||||||
|
manual_password = ""
|
||||||
|
manual_password_placeholder = ""
|
||||||
|
password_mode_generated = ""
|
||||||
|
password_mode_manual = ""
|
||||||
|
password_result_title = ""
|
||||||
|
reset_password = ""
|
||||||
|
reset_password_apply = ""
|
||||||
|
toggle_password_visibility = ""
|
||||||
|
|
||||||
[ui.admin.users.detail.tenants_section]
|
[ui.admin.users.detail.tenants_section]
|
||||||
additional = ""
|
additional = ""
|
||||||
primary = ""
|
primary = ""
|
||||||
|
|||||||
@@ -539,8 +539,12 @@ func main() {
|
|||||||
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
||||||
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
||||||
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
||||||
|
auth.Get("/password/reset/v/:token", authHandler.VerifyPasswordResetPage)
|
||||||
|
auth.Get("/password/reset/ve", authHandler.VerifyPasswordResetPage)
|
||||||
// [Added] Use POST for actual verification triggered by the user
|
// [Added] Use POST for actual verification triggered by the user
|
||||||
auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken)
|
auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken)
|
||||||
|
auth.Post("/password/reset/v/:token", authHandler.ProcessPasswordResetToken)
|
||||||
|
auth.Post("/password/reset/ve", authHandler.ProcessPasswordResetToken)
|
||||||
auth.Post("/password/reset/complete", authHandler.CompletePasswordReset)
|
auth.Post("/password/reset/complete", authHandler.CompletePasswordReset)
|
||||||
auth.Get("/password/policy", authHandler.GetPasswordPolicy)
|
auth.Get("/password/policy", authHandler.GetPasswordPolicy)
|
||||||
auth.Post("/sms", authHandler.SendSms)
|
auth.Post("/sms", authHandler.SendSms)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type NaverSmsRequest struct {
|
|||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
CountryCode string `json:"countryCode"`
|
CountryCode string `json:"countryCode"`
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Messages []SmsMessage `json:"messages"`
|
Messages []SmsMessage `json:"messages"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ const (
|
|||||||
emailCodeTTL = 5 * time.Minute
|
emailCodeTTL = 5 * time.Minute
|
||||||
smsCodeTTL = 3 * time.Minute
|
smsCodeTTL = 3 * time.Minute
|
||||||
prefixPwdResetToken = "pwdreset_token:"
|
prefixPwdResetToken = "pwdreset_token:"
|
||||||
|
prefixPwdResetUsed = "pwdreset_used:"
|
||||||
pwdResetExpiration = 15 * time.Minute
|
pwdResetExpiration = 15 * time.Minute
|
||||||
|
pwdResetUsedExpiration = 2 * time.Minute
|
||||||
minPollInterval = 2 * time.Second
|
minPollInterval = 2 * time.Second
|
||||||
loginCodeExpiration = 10 * time.Minute
|
loginCodeExpiration = 10 * time.Minute
|
||||||
linkResendCooldown = 60 * time.Second
|
linkResendCooldown = 60 * time.Second
|
||||||
@@ -2368,9 +2370,9 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userfrontURL := h.resolveUserfrontURL(c)
|
userfrontURL := h.resolveUserfrontURL(c)
|
||||||
// [Changed] Point to Backend API for verification (which then redirects to Frontend)
|
// 비밀번호 재설정 링크는 backend verify 엔드포인트를 거쳐서 userfront로 이동합니다.
|
||||||
redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL)
|
// 이렇게 해야 메일/SMS 링크 프리뷰나 자동 스캔으로 토큰이 직접 노출되는 경로를 줄일 수 있습니다.
|
||||||
ale.RedirectTo = redirectURL
|
verifyBaseURL := fmt.Sprintf("%s/api/v1/auth/password/reset/v", userfrontURL)
|
||||||
|
|
||||||
// 내부 토큰 발급 + 우리 채널로 전송
|
// 내부 토큰 발급 + 우리 채널로 전송
|
||||||
resetToken := GenerateSecureToken(32)
|
resetToken := GenerateSecureToken(32)
|
||||||
@@ -2390,7 +2392,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to store reset token")
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to store reset token")
|
||||||
}
|
}
|
||||||
|
|
||||||
resetLink := fmt.Sprintf("%s/reset-password?token=%s", userfrontURL, resetToken)
|
resetLink := fmt.Sprintf("%s/%s", verifyBaseURL, resetToken)
|
||||||
ale.RedirectTo = resetLink
|
ale.RedirectTo = resetLink
|
||||||
ale.Operation = "SendPasswordReset"
|
ale.Operation = "SendPasswordReset"
|
||||||
ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
|
ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
|
||||||
@@ -2456,6 +2458,9 @@ func (h *AuthHandler) VerifyPasswordResetPage(c *fiber.Ctx) error {
|
|||||||
if token == "" {
|
if token == "" {
|
||||||
token = c.Query("t")
|
token = c.Query("t")
|
||||||
}
|
}
|
||||||
|
if token == "" {
|
||||||
|
token = c.Params("token")
|
||||||
|
}
|
||||||
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
|
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
|
||||||
@@ -2509,6 +2514,9 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
|
|||||||
token = c.Query("t")
|
token = c.Query("t")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if token == "" {
|
||||||
|
token = c.Params("token")
|
||||||
|
}
|
||||||
ale.Token = token
|
ale.Token = token
|
||||||
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -2583,6 +2591,14 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
|||||||
if resetToken != "" {
|
if resetToken != "" {
|
||||||
val, err := h.RedisService.Get(prefixPwdResetToken + resetToken)
|
val, err := h.RedisService.Get(prefixPwdResetToken + resetToken)
|
||||||
if err != nil || strings.TrimSpace(val) == "" {
|
if err != nil || strings.TrimSpace(val) == "" {
|
||||||
|
if usedLoginID, usedErr := h.RedisService.Get(prefixPwdResetUsed + resetToken); usedErr == nil && strings.TrimSpace(usedLoginID) != "" {
|
||||||
|
ale.Status = fiber.StatusOK
|
||||||
|
ale.LatencyMs = time.Since(startTime)
|
||||||
|
ale.Token = resetToken
|
||||||
|
ale.LoginIDs["loginId"] = strings.TrimSpace(usedLoginID)
|
||||||
|
ale.Log(slog.LevelInfo, "Duplicate reset completion ignored after successful use")
|
||||||
|
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
||||||
|
}
|
||||||
ale.Status = fiber.StatusUnauthorized
|
ale.Status = fiber.StatusUnauthorized
|
||||||
ale.LatencyMs = time.Since(startTime)
|
ale.LatencyMs = time.Since(startTime)
|
||||||
ale.ProviderError = "Invalid or expired reset token"
|
ale.ProviderError = "Invalid or expired reset token"
|
||||||
@@ -2652,6 +2668,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
|||||||
ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID))
|
ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID))
|
||||||
if resetToken != "" {
|
if resetToken != "" {
|
||||||
_ = h.RedisService.Delete(prefixPwdResetToken + resetToken)
|
_ = h.RedisService.Delete(prefixPwdResetToken + resetToken)
|
||||||
|
_ = h.RedisService.Set(prefixPwdResetUsed+resetToken, loginID, pwdResetUsedExpiration)
|
||||||
}
|
}
|
||||||
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Mock services
|
// Mock services
|
||||||
type mockEmailService struct{}
|
type mockEmailService struct {
|
||||||
|
lastTo string
|
||||||
|
lastSubject string
|
||||||
|
lastBody string
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockEmailService) SendEmail(to, subject, body string) error { return nil }
|
func (m *mockEmailService) SendEmail(to, subject, body string) error {
|
||||||
|
m.lastTo = to
|
||||||
|
m.lastSubject = subject
|
||||||
|
m.lastBody = body
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type mockSmsService struct{}
|
type mockSmsService struct {
|
||||||
|
lastTo string
|
||||||
|
lastContent string
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockSmsService) SendSms(to, content string) error { return nil }
|
func (m *mockSmsService) SendSms(to, content string) error {
|
||||||
|
m.lastTo = to
|
||||||
|
m.lastContent = content
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App {
|
func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -254,6 +255,65 @@ func TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompletePasswordReset_DuplicateTokenSubmitIsIdempotent(t *testing.T) {
|
||||||
|
const resetToken = "dup-token"
|
||||||
|
const loginID = "user@example.com"
|
||||||
|
const newPassword = "StrongPass1!"
|
||||||
|
|
||||||
|
redis := &testRedisRepo{
|
||||||
|
values: map[string]string{
|
||||||
|
prefixPwdResetToken + resetToken: loginID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
idp := &mockIdpProvider{
|
||||||
|
userExists: true,
|
||||||
|
err: nil,
|
||||||
|
}
|
||||||
|
h := &AuthHandler{
|
||||||
|
RedisService: redis,
|
||||||
|
IdpProvider: idp,
|
||||||
|
}
|
||||||
|
app := newResetFlowTestApp(h)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"newPassword": newPassword,
|
||||||
|
})
|
||||||
|
url := fmt.Sprintf(
|
||||||
|
"/api/v1/auth/password/reset/complete?token=%s",
|
||||||
|
resetToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
firstReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
firstReq.Header.Set("Content-Type", "application/json")
|
||||||
|
firstResp, err := app.Test(firstReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer firstResp.Body.Close()
|
||||||
|
|
||||||
|
if firstResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected first response to be 200, got %d", firstResp.StatusCode)
|
||||||
|
}
|
||||||
|
if idp.updateCallCount != 1 {
|
||||||
|
t.Fatalf("expected first request to update password once, got %d", idp.updateCallCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
secondReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
secondReq.Header.Set("Content-Type", "application/json")
|
||||||
|
secondResp, err := app.Test(secondReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer secondResp.Body.Close()
|
||||||
|
|
||||||
|
if secondResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected duplicate response to be 200, got %d", secondResp.StatusCode)
|
||||||
|
}
|
||||||
|
if idp.updateCallCount != 1 {
|
||||||
|
t.Fatalf("expected duplicate request not to update password again, got %d", idp.updateCallCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
|
func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
|
||||||
const token = "tok-enc"
|
const token = "tok-enc"
|
||||||
const loginID = "user+alias@example.com"
|
const loginID = "user+alias@example.com"
|
||||||
@@ -295,6 +355,102 @@ func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPasswordResetVerifyAlias_AcceptsShortVePath(t *testing.T) {
|
||||||
|
const token = "tok-ve"
|
||||||
|
const loginID = "user@example.com"
|
||||||
|
|
||||||
|
redis := &testRedisRepo{
|
||||||
|
values: map[string]string{
|
||||||
|
prefixPwdResetToken + token: loginID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := &AuthHandler{
|
||||||
|
RedisService: redis,
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/api/v1/auth/password/reset/ve", h.VerifyPasswordResetPage)
|
||||||
|
app.Post("/api/v1/auth/password/reset/ve", h.ProcessPasswordResetToken)
|
||||||
|
|
||||||
|
getReq := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/auth/password/reset/ve?token="+token,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
getResp, err := app.Test(getReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer getResp.Body.Close()
|
||||||
|
|
||||||
|
if getResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected alias GET to return 200, got %d", getResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
postReq := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/auth/password/reset/ve?token="+token,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
postResp, err := app.Test(postReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer postResp.Body.Close()
|
||||||
|
|
||||||
|
if postResp.StatusCode != http.StatusFound {
|
||||||
|
t.Fatalf("expected alias POST to return 302, got %d", postResp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordResetVerifyPathToken_AcceptsShortVPath(t *testing.T) {
|
||||||
|
const token = "tok-path"
|
||||||
|
const loginID = "user@example.com"
|
||||||
|
|
||||||
|
redis := &testRedisRepo{
|
||||||
|
values: map[string]string{
|
||||||
|
prefixPwdResetToken + token: loginID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := &AuthHandler{
|
||||||
|
RedisService: redis,
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/api/v1/auth/password/reset/v/:token", h.VerifyPasswordResetPage)
|
||||||
|
app.Post("/api/v1/auth/password/reset/v/:token", h.ProcessPasswordResetToken)
|
||||||
|
|
||||||
|
getReq := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/auth/password/reset/v/"+token,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
getResp, err := app.Test(getReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer getResp.Body.Close()
|
||||||
|
|
||||||
|
if getResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected path-token GET to return 200, got %d", getResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
postReq := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/auth/password/reset/v/"+token,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
postResp, err := app.Test(postReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer postResp.Body.Close()
|
||||||
|
|
||||||
|
if postResp.StatusCode != http.StatusFound {
|
||||||
|
t.Fatalf("expected path-token POST to return 302, got %d", postResp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) {
|
func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) {
|
||||||
h := &AuthHandler{}
|
h := &AuthHandler{}
|
||||||
app := newResetInitAppWithErrorCodeEnricher(h)
|
app := newResetInitAppWithErrorCodeEnricher(h)
|
||||||
@@ -326,3 +482,40 @@ func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T)
|
|||||||
t.Fatalf("expected code=bad_request, got %v", got["code"])
|
t.Fatalf("expected code=bad_request, got %v", got["code"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInitiatePasswordReset_SmsContainsVerifyLink(t *testing.T) {
|
||||||
|
t.Setenv("USERFRONT_URL", "https://sss.hmac.kr")
|
||||||
|
|
||||||
|
redis := &testRedisRepo{values: map[string]string{}}
|
||||||
|
smsSvc := &mockSmsService{}
|
||||||
|
h := &AuthHandler{
|
||||||
|
RedisService: redis,
|
||||||
|
IdpProvider: &mockIdpProvider{},
|
||||||
|
SmsService: smsSvc,
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Post("/api/v1/auth/password/reset/init", h.InitiatePasswordReset)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"loginId": "01012345678",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/init", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(smsSvc.lastContent, "/api/v1/auth/password/reset/v/") {
|
||||||
|
t.Fatalf("expected SMS to contain short path verify link, got %q", smsSvc.lastContent)
|
||||||
|
}
|
||||||
|
if strings.Contains(smsSvc.lastContent, "/reset-password?token=") {
|
||||||
|
t.Fatalf("expected direct reset-password link to be removed, got %q", smsSvc.lastContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type mockIdpProvider struct {
|
|||||||
err error
|
err error
|
||||||
initiateLinkErr error
|
initiateLinkErr error
|
||||||
updateCalled bool
|
updateCalled bool
|
||||||
|
updateCallCount int
|
||||||
updatedLoginID string
|
updatedLoginID string
|
||||||
updatedPassword string
|
updatedPassword string
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,7 @@ func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthIn
|
|||||||
|
|
||||||
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||||
m.updateCalled = true
|
m.updateCalled = true
|
||||||
|
m.updateCallCount++
|
||||||
m.updatedLoginID = loginID
|
m.updatedLoginID = loginID
|
||||||
m.updatedPassword = newPassword
|
m.updatedPassword = newPassword
|
||||||
return m.err
|
return m.err
|
||||||
|
|||||||
@@ -1203,12 +1203,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finalLoginID := extractTraitString(traits, "id")
|
explicitLoginID := strings.TrimSpace(extractTraitString(traits, "id"))
|
||||||
userEmail := extractTraitString(traits, "email")
|
userEmail := extractTraitString(traits, "email")
|
||||||
userPhone := extractTraitString(traits, "phone")
|
userPhone := extractTraitString(traits, "phone_number")
|
||||||
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
|
if err := domain.ValidateLoginID(explicitLoginID, userEmail, userPhone); err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
|
finalLoginID := resolvePasswordLoginID(traits)
|
||||||
|
|
||||||
state := normalizeKratosState(req.Status)
|
state := normalizeKratosState(req.Status)
|
||||||
|
|
||||||
@@ -1234,7 +1235,10 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.Password != nil && *req.Password != "" {
|
if req.Password != nil && *req.Password != "" {
|
||||||
if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil {
|
if h.OryProvider == nil {
|
||||||
|
return errorJSON(c, fiber.StatusServiceUnavailable, "password provider not available")
|
||||||
|
}
|
||||||
|
if err := h.OryProvider.UpdateUserPassword(finalLoginID, *req.Password, nil); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1508,6 +1512,16 @@ func extractTraitString(traits map[string]interface{}, key string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolvePasswordLoginID(traits map[string]interface{}) string {
|
||||||
|
if loginID := strings.TrimSpace(extractTraitString(traits, "id")); loginID != "" {
|
||||||
|
return loginID
|
||||||
|
}
|
||||||
|
if email := strings.TrimSpace(extractTraitString(traits, "email")); email != "" {
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(extractTraitString(traits, "phone_number"))
|
||||||
|
}
|
||||||
|
|
||||||
// syncLoginID ensures that the 'id' trait (used as Kratos identifier) is in sync with the configured custom field.
|
// syncLoginID ensures that the 'id' trait (used as Kratos identifier) is in sync with the configured custom field.
|
||||||
func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantID string, loginIDField string) {
|
func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantID string, loginIDField string) {
|
||||||
if loginIDField == "" || loginIDField == "id" {
|
if loginIDField == "" || loginIDField == "id" {
|
||||||
|
|||||||
@@ -488,6 +488,117 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_UpdateUser_PasswordUsesProvider(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
mockOry := new(MockOryProvider)
|
||||||
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
|
h := &UserHandler{
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
OryProvider: mockOry,
|
||||||
|
TenantService: mockTenant,
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
|
||||||
|
return h.UpdateUser(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
userID := "u-1"
|
||||||
|
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]interface{}{
|
||||||
|
"id": "dyddus1210",
|
||||||
|
"email": "dyddus1210@gmail.com",
|
||||||
|
"companyCode": "test-tenant",
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||||
|
ID: "t-1",
|
||||||
|
Slug: "test-tenant",
|
||||||
|
}, nil)
|
||||||
|
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
||||||
|
|
||||||
|
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||||
|
return traits["id"] == "dyddus1210"
|
||||||
|
}), "").Return(&service.KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]interface{}{
|
||||||
|
"id": "dyddus1210",
|
||||||
|
"email": "dyddus1210@gmail.com",
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
mockOry.On("UpdateUserPassword", "dyddus1210", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"password": "asdfzxcv1234!",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
mockOry.AssertExpectations(t)
|
||||||
|
mockKratos.AssertNotCalled(t, "UpdateIdentityPassword", mock.Anything, mock.Anything, mock.Anything)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_UpdateUser_PasswordFallsBackToEmail(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
mockOry := new(MockOryProvider)
|
||||||
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
|
h := &UserHandler{
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
OryProvider: mockOry,
|
||||||
|
TenantService: mockTenant,
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
|
||||||
|
return h.UpdateUser(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
userID := "u-2"
|
||||||
|
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]interface{}{
|
||||||
|
"email": "dyddus1210@gmail.com",
|
||||||
|
"companyCode": "test-tenant",
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||||
|
ID: "t-1",
|
||||||
|
Slug: "test-tenant",
|
||||||
|
}, nil)
|
||||||
|
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
||||||
|
|
||||||
|
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||||
|
return traits["email"] == "dyddus1210@gmail.com"
|
||||||
|
}), "").Return(&service.KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]interface{}{
|
||||||
|
"email": "dyddus1210@gmail.com",
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
mockOry.On("UpdateUserPassword", "dyddus1210@gmail.com", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"password": "asdfzxcv1234!",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
mockOry.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
|
func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
|
||||||
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
|
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
|
|||||||
@@ -11,12 +11,18 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type KratosIdentity struct {
|
type KratosIdentity struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
SchemaID string `json:"schema_id,omitempty"`
|
||||||
Traits map[string]interface{} `json:"traits"`
|
Traits map[string]interface{} `json:"traits"`
|
||||||
State string `json:"state,omitempty"`
|
State string `json:"state,omitempty"`
|
||||||
|
MetadataAdmin interface{} `json:"metadata_admin,omitempty"`
|
||||||
|
MetadataPublic interface{} `json:"metadata_public,omitempty"`
|
||||||
|
ExternalID string `json:"external_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -172,20 +178,54 @@ func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
||||||
patchOps := []map[string]interface{}{
|
identity, err := s.GetIdentity(ctx, identityID)
|
||||||
{
|
|
||||||
"op": "add",
|
|
||||||
"path": "/credentials/password/config/password",
|
|
||||||
"value": newPassword,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(patchOps)
|
|
||||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
if identity == nil {
|
||||||
|
return fmt.Errorf("kratos admin identity not found: %s", identityID)
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := hashPasswordForKratosAdmin(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"schema_id": identity.SchemaID,
|
||||||
|
"traits": identity.Traits,
|
||||||
|
"state": identity.State,
|
||||||
|
"credentials": map[string]interface{}{
|
||||||
|
"password": map[string]interface{}{
|
||||||
|
"config": map[string]string{
|
||||||
|
"hashed_password": hashedPassword,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if payload["schema_id"] == "" {
|
||||||
|
payload["schema_id"] = "default"
|
||||||
|
}
|
||||||
|
if payload["state"] == "" {
|
||||||
|
payload["state"] = "active"
|
||||||
|
}
|
||||||
|
if identity.MetadataAdmin != nil {
|
||||||
|
payload["metadata_admin"] = identity.MetadataAdmin
|
||||||
|
}
|
||||||
|
if identity.MetadataPublic != nil {
|
||||||
|
payload["metadata_public"] = identity.MetadataPublic
|
||||||
|
}
|
||||||
|
if identity.ExternalID != "" {
|
||||||
|
payload["external_id"] = identity.ExternalID
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := s.httpClient().Do(req)
|
resp, err := s.httpClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -199,6 +239,14 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hashPasswordForKratosAdmin(password string) (string, error) {
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hashed), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *kratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
|
func (s *kratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
|
||||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다.
|
// OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다.
|
||||||
@@ -711,20 +713,53 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re
|
|||||||
return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
|
return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
|
||||||
}
|
}
|
||||||
|
|
||||||
patchOps := []map[string]interface{}{
|
identity, err := o.getIdentity(identityID)
|
||||||
{
|
if err != nil {
|
||||||
"op": "add",
|
return fmt.Errorf("ory provider: load identity failed: %w", err)
|
||||||
"path": "/credentials/password/config/password",
|
}
|
||||||
"value": newPassword,
|
if identity == nil {
|
||||||
},
|
return fmt.Errorf("ory provider: identity payload missing for loginID=%s", loginID)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, _ := json.Marshal(patchOps)
|
hashedPassword, err := hashPasswordForKratos(newPassword)
|
||||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
if err != nil {
|
||||||
|
return fmt.Errorf("ory provider: hash password failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"schema_id": identity.SchemaID,
|
||||||
|
"traits": identity.Traits,
|
||||||
|
"state": identity.State,
|
||||||
|
"credentials": map[string]interface{}{
|
||||||
|
"password": map[string]interface{}{
|
||||||
|
"config": map[string]string{
|
||||||
|
"hashed_password": hashedPassword,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if payload["schema_id"] == "" {
|
||||||
|
payload["schema_id"] = "default"
|
||||||
|
}
|
||||||
|
if payload["state"] == "" {
|
||||||
|
payload["state"] = "active"
|
||||||
|
}
|
||||||
|
if identity.MetadataAdmin != nil {
|
||||||
|
payload["metadata_admin"] = identity.MetadataAdmin
|
||||||
|
}
|
||||||
|
if identity.MetadataPublic != nil {
|
||||||
|
payload["metadata_public"] = identity.MetadataPublic
|
||||||
|
}
|
||||||
|
if identity.ExternalID != "" {
|
||||||
|
payload["external_id"] = identity.ExternalID
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ory provider: build request failed: %w", err)
|
return fmt.Errorf("ory provider: build request failed: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := o.httpClient().Do(req)
|
resp, err := o.httpClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -789,6 +824,41 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
|
|||||||
return identities[0].ID, nil
|
return identities[0].ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *OryProvider) getIdentity(identityID string) (*KratosIdentity, error) {
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := o.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
return nil, fmt.Errorf("ory provider: get identity failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity KratosIdentity
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &identity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashPasswordForKratos(password string) (string, error) {
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hashed), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OryProvider) httpClient() *http.Client {
|
func (o *OryProvider) httpClient() *http.Client {
|
||||||
if o.HTTPClient != nil {
|
if o.HTTPClient != nil {
|
||||||
return o.HTTPClient
|
return o.HTTPClient
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func TestUpdateUserPassword_Success(t *testing.T) {
|
|||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
|
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
|
||||||
|
if r.URL.Path == "/admin/identities" {
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
if got := q.Get("credentials_identifier"); got != loginID {
|
if got := q.Get("credentials_identifier"); got != loginID {
|
||||||
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
|
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
|
||||||
@@ -53,10 +54,29 @@ func TestUpdateUserPassword_Success(t *testing.T) {
|
|||||||
{"id": identityID},
|
{"id": identityID},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPatch:
|
}
|
||||||
|
if r.URL.Path != "/admin/identities/"+identityID {
|
||||||
|
t.Fatalf("unexpected identity lookup path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"id": identityID,
|
||||||
|
"schema_id": "default",
|
||||||
|
"state": "active",
|
||||||
|
"traits": map[string]interface{}{
|
||||||
|
"email": loginID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPut:
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
if !strings.Contains(string(body), newPassword) {
|
if !strings.Contains(string(body), "\"hashed_password\"") {
|
||||||
t.Fatalf("payload missing new password, body=%s", string(body))
|
t.Fatalf("payload missing hashed_password, body=%s", string(body))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(body), newPassword) {
|
||||||
|
t.Fatalf("payload must not contain plain password, body=%s", string(body))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "\"schema_id\":\"default\"") {
|
||||||
|
t.Fatalf("payload missing schema_id, body=%s", string(body))
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
@@ -99,11 +119,25 @@ func TestUpdateUserPassword_ServerError(t *testing.T) {
|
|||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
|
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
|
||||||
|
if r.URL.Path == "/admin/identities" {
|
||||||
_ = json.NewEncoder(w).Encode([]map[string]string{
|
_ = json.NewEncoder(w).Encode([]map[string]string{
|
||||||
{"id": "abc"},
|
{"id": "abc"},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPatch:
|
}
|
||||||
|
if r.URL.Path == "/admin/identities/abc" {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"id": "abc",
|
||||||
|
"schema_id": "default",
|
||||||
|
"state": "active",
|
||||||
|
"traits": map[string]interface{}{
|
||||||
|
"email": "user@example.com",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||||
|
case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPut:
|
||||||
http.Error(w, "boom", http.StatusInternalServerError)
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const naverSMSMaxBytes = 90
|
||||||
|
|
||||||
type SmsServiceImpl struct {
|
type SmsServiceImpl struct {
|
||||||
accessKey string
|
accessKey string
|
||||||
secretKey string
|
secretKey string
|
||||||
@@ -46,17 +48,11 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
|
|||||||
// Naver SENS API requires phone number without '+'
|
// Naver SENS API requires phone number without '+'
|
||||||
sanitizedTo := strings.Replace(to, "+", "", 1)
|
sanitizedTo := strings.Replace(to, "+", "", 1)
|
||||||
|
|
||||||
reqBody := domain.NaverSmsRequest{
|
reqBody := buildNaverSmsRequest(s.senderPhone, sanitizedTo, content)
|
||||||
Type: "SMS",
|
if reqBody.Type == "LMS" {
|
||||||
ContentType: "COMM",
|
slog.Info("[SmsService] Upgrading message type to LMS due to content length",
|
||||||
CountryCode: "82",
|
"bytes", len([]byte(content)),
|
||||||
From: s.senderPhone,
|
)
|
||||||
Content: content,
|
|
||||||
Messages: []domain.SmsMessage{
|
|
||||||
{
|
|
||||||
To: sanitizedTo,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBody, err := json.Marshal(reqBody)
|
jsonBody, err := json.Marshal(reqBody)
|
||||||
@@ -100,6 +96,29 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildNaverSmsRequest(senderPhone, sanitizedTo, content string) domain.NaverSmsRequest {
|
||||||
|
requestType := "SMS"
|
||||||
|
subject := ""
|
||||||
|
if len([]byte(content)) > naverSMSMaxBytes {
|
||||||
|
requestType = "LMS"
|
||||||
|
subject = "[Baron 로그인]"
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.NaverSmsRequest{
|
||||||
|
Type: requestType,
|
||||||
|
ContentType: "COMM",
|
||||||
|
CountryCode: "82",
|
||||||
|
From: senderPhone,
|
||||||
|
Subject: subject,
|
||||||
|
Content: content,
|
||||||
|
Messages: []domain.SmsMessage{
|
||||||
|
{
|
||||||
|
To: sanitizedTo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SmsServiceImpl) makeSignature(method, url, timestamp string) (string, error) {
|
func (s *SmsServiceImpl) makeSignature(method, url, timestamp string) (string, error) {
|
||||||
space := " "
|
space := " "
|
||||||
newLine := "\n"
|
newLine := "\n"
|
||||||
|
|||||||
26
backend/internal/service/sms_service_test.go
Normal file
26
backend/internal/service/sms_service_test.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildNaverSmsRequest_UsesSMSForShortContent(t *testing.T) {
|
||||||
|
req := buildNaverSmsRequest("0262857755", "821012345678", "123456")
|
||||||
|
|
||||||
|
if req.Type != "SMS" {
|
||||||
|
t.Fatalf("expected SMS, got %s", req.Type)
|
||||||
|
}
|
||||||
|
if req.Subject != "" {
|
||||||
|
t.Fatalf("expected empty subject for SMS, got %q", req.Subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildNaverSmsRequest_UsesLMSForLongContent(t *testing.T) {
|
||||||
|
content := "[Baron 로그인] 비밀번호 재설정 링크: http://sso-test.hmac.kr/api/v1/auth/password/reset/v/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||||
|
req := buildNaverSmsRequest("0262857755", "821012345678", content)
|
||||||
|
|
||||||
|
if req.Type != "LMS" {
|
||||||
|
t.Fatalf("expected LMS, got %s", req.Type)
|
||||||
|
}
|
||||||
|
if req.Subject == "" {
|
||||||
|
t.Fatal("expected LMS subject to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -976,7 +976,7 @@ function ClientGeneralPage() {
|
|||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
"ui.dev.clients.general.security.trusted_rp_enable",
|
"ui.dev.clients.general.security.trusted_rp_enable",
|
||||||
"Trusted RP (자체 로그인 UI 사용)",
|
"Headless Login (자체 로그인 UI 사용)",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
@@ -998,7 +998,7 @@ function ClientGeneralPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 4. Public Key Registration (Trusted RP) */}
|
{/* 4. Public Key Registration (Headless Login) */}
|
||||||
{clientType === "pkce" && headlessLoginEnabled && (
|
{clientType === "pkce" && headlessLoginEnabled && (
|
||||||
<Card className="glass-panel border-primary/20">
|
<Card className="glass-panel border-primary/20">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -1013,7 +1013,7 @@ function ClientGeneralPage() {
|
|||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t(
|
{t(
|
||||||
"msg.dev.clients.general.public_key.subtitle",
|
"msg.dev.clients.general.public_key.subtitle",
|
||||||
"Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다.",
|
"Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다.",
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -390,12 +390,12 @@ subtitle = "Define the permission scopes this application can request."
|
|||||||
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
|
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
|
||||||
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
|
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
|
||||||
subtitle = "Select application type. Security level determines authentication method."
|
subtitle = "Select application type. Security level determines authentication method."
|
||||||
trusted_help = "Operate as a trusted RP using private_key_jwt and public key registration. Headless login is only available for this profile."
|
trusted_help = "Operate as Headless Login using private_key_jwt and public key registration. Headless login is only available for this profile."
|
||||||
|
|
||||||
[msg.dev.clients.general.public_key]
|
[msg.dev.clients.general.public_key]
|
||||||
auth_method_client_secret_basic_help = "Standard authentication method for server-side applications."
|
auth_method_client_secret_basic_help = "Standard authentication method for server-side applications."
|
||||||
auth_method_none_help = "Use this for PKCE-based public clients."
|
auth_method_none_help = "Use this for PKCE-based public clients."
|
||||||
auth_method_private_key_jwt_help = "Signed key-based client authentication recommended for trusted RP bootstrap and JAR verification."
|
auth_method_private_key_jwt_help = "Signed key-based client authentication recommended for Headless Login bootstrap and JAR verification."
|
||||||
guide_example = "Recommended example: https://rp.example.com/.well-known/jwks.json"
|
guide_example = "Recommended example: https://rp.example.com/.well-known/jwks.json"
|
||||||
guide_intro = "A JWKS URI is not created by Baron. It is the URL where the RP backend exposes its public key."
|
guide_intro = "A JWKS URI is not created by Baron. It is the URL where the RP backend exposes its public key."
|
||||||
guide_step_1 = "Generate a key pair on the RP server and keep the private key only in the RP backend."
|
guide_step_1 = "Generate a key pair on the RP server and keep the private key only in the RP backend."
|
||||||
@@ -406,7 +406,7 @@ jwks_inline_help = "Prefer the SSH-RSA public key format first. If you paste an
|
|||||||
jwks_uri_help = "Enter the public key endpoint URL exposed by the RP backend. Example: https://rp.example.com/.well-known/jwks.json"
|
jwks_uri_help = "Enter the public key endpoint URL exposed by the RP backend. Example: https://rp.example.com/.well-known/jwks.json"
|
||||||
request_object_alg_help = "Specify the JAR (Request Object) signing algorithm used for headless login."
|
request_object_alg_help = "Specify the JAR (Request Object) signing algorithm used for headless login."
|
||||||
source_help = "Register the JWKS URI served by the RP so Baron can verify the public key."
|
source_help = "Register the JWKS URI served by the RP so Baron can verify the public key."
|
||||||
subtitle = "Manage the public key and headless login settings required for trusted RP evaluation."
|
subtitle = "Manage the public key and headless login settings required for Headless Login evaluation."
|
||||||
|
|
||||||
[msg.dev.clients.general.public_key.validation]
|
[msg.dev.clients.general.public_key.validation]
|
||||||
headless_requires_alg = "Headless login requires a Request Object Signing Algorithm."
|
headless_requires_alg = "Headless login requires a Request Object Signing Algorithm."
|
||||||
@@ -1392,9 +1392,9 @@ delete = "Delete"
|
|||||||
[ui.dev.clients.general.security]
|
[ui.dev.clients.general.security]
|
||||||
private = "Server Side App"
|
private = "Server Side App"
|
||||||
pkce = "PKCE"
|
pkce = "PKCE"
|
||||||
trusted = "Trusted RP"
|
trusted = "Headless Login"
|
||||||
title = "Security Settings"
|
title = "Security Settings"
|
||||||
trusted_rp_enable = "Trusted RP (Custom Login UI)"
|
trusted_rp_enable = "Headless Login (Custom Login UI)"
|
||||||
trusted_rp_enable_help = "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page."
|
trusted_rp_enable_help = "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page."
|
||||||
|
|
||||||
[ui.dev.clients.general.public_key]
|
[ui.dev.clients.general.public_key]
|
||||||
|
|||||||
@@ -390,12 +390,12 @@ subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
|||||||
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
||||||
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
|
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
|
||||||
subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
|
subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
|
||||||
trusted_help = "private_key_jwt와 공개키 등록을 사용해 trusted RP로 운영합니다.\nHeadless Login은 이 프로필에서만 사용할 수 있습니다."
|
trusted_help = "private_key_jwt와 공개키 등록을 사용해 Headless Login으로 운영합니다.\nHeadless Login은 이 프로필에서만 사용할 수 있습니다."
|
||||||
|
|
||||||
[msg.dev.clients.general.public_key]
|
[msg.dev.clients.general.public_key]
|
||||||
auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다."
|
auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다."
|
||||||
auth_method_none_help = "PKCE 기반 public client에 사용하는 방식입니다."
|
auth_method_none_help = "PKCE 기반 public client에 사용하는 방식입니다."
|
||||||
auth_method_private_key_jwt_help = "Trusted RP bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다."
|
auth_method_private_key_jwt_help = "Headless Login bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다."
|
||||||
guide_example = "권장 예시: https://rp.example.com/.well-known/jwks.json"
|
guide_example = "권장 예시: https://rp.example.com/.well-known/jwks.json"
|
||||||
guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다."
|
guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다."
|
||||||
guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다."
|
guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다."
|
||||||
@@ -406,7 +406,7 @@ jwks_inline_help = "SSH-RSA 공개키 형식을 우선 권장합니다. 'ssh-rsa
|
|||||||
jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json"
|
jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json"
|
||||||
request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다."
|
request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다."
|
||||||
source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다."
|
source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다."
|
||||||
subtitle = "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다."
|
subtitle = "Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다."
|
||||||
|
|
||||||
[msg.dev.clients.general.public_key.validation]
|
[msg.dev.clients.general.public_key.validation]
|
||||||
headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다."
|
headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다."
|
||||||
@@ -1393,7 +1393,7 @@ delete = "삭제"
|
|||||||
private = "Server side App"
|
private = "Server side App"
|
||||||
pkce = "PKCE"
|
pkce = "PKCE"
|
||||||
title = "보안 설정"
|
title = "보안 설정"
|
||||||
trusted_rp_enable = "Trusted RP (자체 로그인 UI 사용)"
|
trusted_rp_enable = "Headless Login (자체 로그인 UI 사용)"
|
||||||
trusted_rp_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다."
|
trusted_rp_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다."
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
|
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("pkce trusted rp with inline ssh-rsa key should persist mapped payload", async ({
|
test("pkce headless login with inline ssh-rsa key should persist mapped payload", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const state = {
|
const state = {
|
||||||
@@ -139,7 +139,7 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole("switch", {
|
.getByRole("switch", {
|
||||||
name: /Trusted RP \(자체 로그인 UI 사용\)|Trusted RP \(Custom Login UI\)/i,
|
name: /Headless Login \(자체 로그인 UI 사용\)|Headless Login \(Custom Login UI\)/i,
|
||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
@@ -158,15 +158,20 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() => state.clients[0]?.tokenEndpointAuthMethod)
|
.poll(() => state.clients[0]?.tokenEndpointAuthMethod)
|
||||||
.toBe("private_key_jwt");
|
.toBe("none");
|
||||||
await expect
|
await expect
|
||||||
.poll(() => state.clients[0]?.metadata?.headless_login_enabled)
|
.poll(() => state.clients[0]?.metadata?.headless_login_enabled)
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => state.clients[0]?.metadata?.headless_token_endpoint_auth_method,
|
||||||
|
)
|
||||||
|
.toBe("private_key_jwt");
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
() =>
|
() =>
|
||||||
(
|
(
|
||||||
state.clients[0]?.jwks as {
|
state.clients[0]?.metadata?.headless_jwks as {
|
||||||
keys?: Array<{ kty?: string; alg?: string }>;
|
keys?: Array<{ kty?: string; alg?: string }>;
|
||||||
}
|
}
|
||||||
)?.keys?.[0]?.kty,
|
)?.keys?.[0]?.kty,
|
||||||
@@ -176,7 +181,7 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
.poll(
|
.poll(
|
||||||
() =>
|
() =>
|
||||||
(
|
(
|
||||||
state.clients[0]?.jwks as {
|
state.clients[0]?.metadata?.headless_jwks as {
|
||||||
keys?: Array<{ kty?: string; alg?: string }>;
|
keys?: Array<{ kty?: string; alg?: string }>;
|
||||||
}
|
}
|
||||||
)?.keys?.[0]?.alg,
|
)?.keys?.[0]?.alg,
|
||||||
|
|||||||
@@ -313,6 +313,10 @@ not_found = "Not Found"
|
|||||||
update_error = "Failed to User Edit."
|
update_error = "Failed to User Edit."
|
||||||
update_success = "Update Success"
|
update_success = "Update Success"
|
||||||
password_generated = "A secure password has been generated."
|
password_generated = "A secure password has been generated."
|
||||||
|
password_generated_help = "Generate a temporary password that meets the security policy and apply it immediately."
|
||||||
|
password_manual_required = "Please enter a password."
|
||||||
|
reset_password_help = "Force-reset the user's password and apply either an auto-generated password or a manually entered one."
|
||||||
|
self_password_reset_blocked = "Please change your own password from the UserFront settings page."
|
||||||
|
|
||||||
[msg.admin.users.detail.form]
|
[msg.admin.users.detail.form]
|
||||||
field_required = "Required."
|
field_required = "Required."
|
||||||
@@ -1155,6 +1159,11 @@ back = "Back"
|
|||||||
edit_title = "Edit Title"
|
edit_title = "Edit Title"
|
||||||
title = "User Details"
|
title = "User Details"
|
||||||
generate_password = "Auto Generate"
|
generate_password = "Auto Generate"
|
||||||
|
password_mode_generated = "Auto Generate"
|
||||||
|
password_mode_manual = "Manual Entry"
|
||||||
|
password_result_title = "Reset Password"
|
||||||
|
reset_password_apply = "Apply Password"
|
||||||
|
toggle_password_visibility = "Toggle password visibility"
|
||||||
|
|
||||||
[ui.admin.users.detail.breadcrumb]
|
[ui.admin.users.detail.breadcrumb]
|
||||||
section = "Users"
|
section = "Users"
|
||||||
|
|||||||
@@ -685,6 +685,10 @@ not_found = "사용자를 찾을 수 없습니다."
|
|||||||
update_error = "사용자 수정에 실패했습니다."
|
update_error = "사용자 수정에 실패했습니다."
|
||||||
update_success = "사용자 정보가 수정되었습니다."
|
update_success = "사용자 정보가 수정되었습니다."
|
||||||
password_generated = "안전한 비밀번호가 생성되었습니다."
|
password_generated = "안전한 비밀번호가 생성되었습니다."
|
||||||
|
password_generated_help = "보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다."
|
||||||
|
password_manual_required = "비밀번호를 입력해 주세요."
|
||||||
|
reset_password_help = "사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다."
|
||||||
|
self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요."
|
||||||
|
|
||||||
[msg.admin.users.list]
|
[msg.admin.users.list]
|
||||||
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
|
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
|
||||||
@@ -1145,6 +1149,11 @@ back = "목록으로 돌아가기"
|
|||||||
edit_title = "정보 수정"
|
edit_title = "정보 수정"
|
||||||
title = "사용자 상세"
|
title = "사용자 상세"
|
||||||
generate_password = "자동 생성"
|
generate_password = "자동 생성"
|
||||||
|
password_mode_generated = "자동 생성"
|
||||||
|
password_mode_manual = "수동 입력"
|
||||||
|
password_result_title = "Reset Password"
|
||||||
|
reset_password_apply = "비밀번호 적용"
|
||||||
|
toggle_password_visibility = "비밀번호 표시 전환"
|
||||||
|
|
||||||
[ui.admin.users.list]
|
[ui.admin.users.list]
|
||||||
add = "사용자 추가"
|
add = "사용자 추가"
|
||||||
|
|||||||
@@ -685,6 +685,10 @@ not_found = ""
|
|||||||
update_error = ""
|
update_error = ""
|
||||||
update_success = ""
|
update_success = ""
|
||||||
password_generated = ""
|
password_generated = ""
|
||||||
|
password_generated_help = ""
|
||||||
|
password_manual_required = ""
|
||||||
|
reset_password_help = ""
|
||||||
|
self_password_reset_blocked = ""
|
||||||
|
|
||||||
[msg.admin.users.list]
|
[msg.admin.users.list]
|
||||||
delete_confirm = ""
|
delete_confirm = ""
|
||||||
@@ -1145,6 +1149,11 @@ back = ""
|
|||||||
edit_title = ""
|
edit_title = ""
|
||||||
title = ""
|
title = ""
|
||||||
generate_password = ""
|
generate_password = ""
|
||||||
|
password_mode_generated = ""
|
||||||
|
password_mode_manual = ""
|
||||||
|
password_result_title = ""
|
||||||
|
reset_password_apply = ""
|
||||||
|
toggle_password_visibility = ""
|
||||||
|
|
||||||
[ui.admin.users.list]
|
[ui.admin.users.list]
|
||||||
add = ""
|
add = ""
|
||||||
|
|||||||
@@ -7,32 +7,94 @@ type RequestCapture = {
|
|||||||
clientLogs: string[];
|
clientLogs: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIGNIN_PASSWORD_TAB_X = 522;
|
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||||
const SIGNIN_TAB_Y = 158;
|
await page.waitForTimeout(300);
|
||||||
const SIGNIN_LOGIN_ID_X = 640;
|
const button = page.getByRole('button', { name: 'Enable accessibility' });
|
||||||
const SIGNIN_LOGIN_ID_Y = 245;
|
if (await button.count()) {
|
||||||
const SIGNIN_PASSWORD_X = 640;
|
await button.click({ force: true });
|
||||||
const SIGNIN_PASSWORD_Y = 311;
|
const placeholder = page.locator('flt-semantics-placeholder');
|
||||||
const SIGNIN_SUBMIT_X = 640;
|
if (await placeholder.count()) {
|
||||||
const SIGNIN_SUBMIT_Y = 381;
|
await placeholder.first().click({ force: true });
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const RESET_NEW_PASSWORD_X = 640;
|
type ScreenCoords = {
|
||||||
const RESET_NEW_PASSWORD_Y = 382;
|
signinPasswordTabX: number;
|
||||||
const RESET_CONFIRM_PASSWORD_X = 640;
|
signinTabY: number;
|
||||||
const RESET_CONFIRM_PASSWORD_Y = 464;
|
signinLoginIdX: number;
|
||||||
const RESET_SUBMIT_X = 640;
|
signinLoginIdY: number;
|
||||||
const RESET_SUBMIT_Y = 534;
|
signinPasswordX: number;
|
||||||
|
signinPasswordY: number;
|
||||||
|
signinSubmitX: number;
|
||||||
|
signinSubmitY: number;
|
||||||
|
resetNewPasswordX: number;
|
||||||
|
resetNewPasswordY: number;
|
||||||
|
resetConfirmPasswordX: number;
|
||||||
|
resetConfirmPasswordY: number;
|
||||||
|
resetSubmitX: number;
|
||||||
|
resetSubmitY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const desktopCoords: ScreenCoords = {
|
||||||
|
signinPasswordTabX: 522,
|
||||||
|
signinTabY: 158,
|
||||||
|
signinLoginIdX: 640,
|
||||||
|
signinLoginIdY: 245,
|
||||||
|
signinPasswordX: 640,
|
||||||
|
signinPasswordY: 311,
|
||||||
|
signinSubmitX: 640,
|
||||||
|
signinSubmitY: 381,
|
||||||
|
resetNewPasswordX: 640,
|
||||||
|
resetNewPasswordY: 382,
|
||||||
|
resetConfirmPasswordX: 640,
|
||||||
|
resetConfirmPasswordY: 464,
|
||||||
|
resetSubmitX: 640,
|
||||||
|
resetSubmitY: 534,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mobileCoords: ScreenCoords = {
|
||||||
|
signinPasswordTabX: 90,
|
||||||
|
signinTabY: 158,
|
||||||
|
signinLoginIdX: 206,
|
||||||
|
signinLoginIdY: 268,
|
||||||
|
signinPasswordX: 206,
|
||||||
|
signinPasswordY: 334,
|
||||||
|
signinSubmitX: 206,
|
||||||
|
signinSubmitY: 399,
|
||||||
|
resetNewPasswordX: 206,
|
||||||
|
resetNewPasswordY: 382,
|
||||||
|
resetConfirmPasswordX: 206,
|
||||||
|
resetConfirmPasswordY: 464,
|
||||||
|
resetSubmitX: 206,
|
||||||
|
resetSubmitY: 534,
|
||||||
|
};
|
||||||
|
|
||||||
|
function coordsFor(page: Page): ScreenCoords {
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMobileProject(page: Page): boolean {
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
return (viewport?.width ?? 1280) <= 500;
|
||||||
|
}
|
||||||
|
|
||||||
async function clickPasswordTab(page: Page): Promise<void> {
|
async function clickPasswordTab(page: Page): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
await page.waitForTimeout(900);
|
await page.waitForTimeout(900);
|
||||||
const pane = page.locator('flt-glass-pane');
|
const pane = page.locator('flt-glass-pane');
|
||||||
await pane.click({
|
await pane.click({
|
||||||
position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y },
|
position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(120);
|
await page.waitForTimeout(120);
|
||||||
await pane.click({
|
await pane.click({
|
||||||
position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y },
|
position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
@@ -47,6 +109,68 @@ async function fillAt(page: Page, x: number, y: number, value: string): Promise<
|
|||||||
await page.keyboard.type(value);
|
await page.keyboard.type(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fillPasswordLoginForm(
|
||||||
|
page: Page,
|
||||||
|
loginId: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await enableFlutterAccessibility(page);
|
||||||
|
const inputs = page.getByRole('textbox');
|
||||||
|
await inputs.nth(0).fill(loginId);
|
||||||
|
await inputs.nth(1).fill(password);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
|
await fillAt(page, coords.signinLoginIdX, coords.signinLoginIdY, loginId);
|
||||||
|
await fillAt(page, coords.signinPasswordX, coords.signinPasswordY, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPasswordLogin(page: Page): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await page.getByRole('button', { name: '로그인' }).click({ force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
|
await page.locator('flt-glass-pane').click({
|
||||||
|
position: { x: coords.signinSubmitX, y: coords.signinSubmitY },
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillResetPasswordForm(page: Page, password: string): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await enableFlutterAccessibility(page);
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: /^새 비밀번호$/ })
|
||||||
|
.fill(password);
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: /^새 비밀번호 확인$/ })
|
||||||
|
.fill(password);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
|
await fillAt(page, coords.resetNewPasswordX, coords.resetNewPasswordY, password);
|
||||||
|
await fillAt(
|
||||||
|
page,
|
||||||
|
coords.resetConfirmPasswordX,
|
||||||
|
coords.resetConfirmPasswordY,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitResetPassword(page: Page): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await page.getByRole('button', { name: '비밀번호 변경' }).click({ force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
|
await page.locator('flt-glass-pane').click({
|
||||||
|
position: { x: coords.resetSubmitX, y: coords.resetSubmitY },
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void> {
|
async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void> {
|
||||||
await page.route('**/api/v1/**', async (route: Route) => {
|
await page.route('**/api/v1/**', async (route: Route) => {
|
||||||
const requestUrl = new URL(route.request().url());
|
const requestUrl = new URL(route.request().url());
|
||||||
@@ -186,17 +310,17 @@ async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void>
|
|||||||
|
|
||||||
test.describe('UserFront WASM password login and reset', () => {
|
test.describe('UserFront WASM password login and reset', () => {
|
||||||
test('비밀번호 로그인 성공 시 dashboard로 이동하고 토큰을 저장한다', async ({ page }) => {
|
test('비밀번호 로그인 성공 시 dashboard로 이동하고 토큰을 저장한다', async ({ page }) => {
|
||||||
|
test.skip(
|
||||||
|
isMobileProject(page),
|
||||||
|
'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.',
|
||||||
|
);
|
||||||
const capture: RequestCapture = { clientLogs: [] };
|
const capture: RequestCapture = { clientLogs: [] };
|
||||||
await mockAuthApis(page, capture);
|
await mockAuthApis(page, capture);
|
||||||
|
|
||||||
await page.goto('/ko/signin');
|
await page.goto('/ko/signin');
|
||||||
await clickPasswordTab(page);
|
await clickPasswordTab(page);
|
||||||
await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com');
|
await fillPasswordLoginForm(page, 'e2e@example.com', 'ValidPass1!');
|
||||||
await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'ValidPass1!');
|
await submitPasswordLogin(page);
|
||||||
await page.locator('flt-glass-pane').click({
|
|
||||||
position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y },
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||||
|
|
||||||
@@ -210,17 +334,17 @@ test.describe('UserFront WASM password login and reset', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다', async ({ page }) => {
|
test('비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다', async ({ page }) => {
|
||||||
|
test.skip(
|
||||||
|
isMobileProject(page),
|
||||||
|
'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.',
|
||||||
|
);
|
||||||
const capture: RequestCapture = { clientLogs: [] };
|
const capture: RequestCapture = { clientLogs: [] };
|
||||||
await mockAuthApis(page, capture);
|
await mockAuthApis(page, capture);
|
||||||
|
|
||||||
await page.goto('/ko/signin');
|
await page.goto('/ko/signin');
|
||||||
await clickPasswordTab(page);
|
await clickPasswordTab(page);
|
||||||
await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com');
|
await fillPasswordLoginForm(page, 'e2e@example.com', 'WrongPass1!');
|
||||||
await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'WrongPass1!');
|
await submitPasswordLogin(page);
|
||||||
await page.locator('flt-glass-pane').click({
|
|
||||||
position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y },
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||||
await expect
|
await expect
|
||||||
@@ -246,17 +370,8 @@ test.describe('UserFront WASM password login and reset', () => {
|
|||||||
await page.goto('/ko/reset-password?token=reset-token-e2e');
|
await page.goto('/ko/reset-password?token=reset-token-e2e');
|
||||||
await policyLoaded;
|
await policyLoaded;
|
||||||
await page.waitForTimeout(900);
|
await page.waitForTimeout(900);
|
||||||
await fillAt(page, RESET_NEW_PASSWORD_X, RESET_NEW_PASSWORD_Y, 'ValidPass1!A');
|
await fillResetPasswordForm(page, 'ValidPass1!A');
|
||||||
await fillAt(
|
await submitResetPassword(page);
|
||||||
page,
|
|
||||||
RESET_CONFIRM_PASSWORD_X,
|
|
||||||
RESET_CONFIRM_PASSWORD_Y,
|
|
||||||
'ValidPass1!A',
|
|
||||||
);
|
|
||||||
await page.locator('flt-glass-pane').click({
|
|
||||||
position: { x: RESET_SUBMIT_X, y: RESET_SUBMIT_Y },
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
|
|||||||
@@ -6,12 +6,50 @@ type ProfileState = {
|
|||||||
putBodies: Array<Record<string, unknown>>;
|
putBodies: Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROFILE_DEPARTMENT_EDIT_X = 1170;
|
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||||
const PROFILE_DEPARTMENT_EDIT_Y = 680;
|
const button = page.getByRole('button', { name: 'Enable accessibility' });
|
||||||
const PROFILE_DEPARTMENT_INPUT_X = 110;
|
if (await button.count()) {
|
||||||
const PROFILE_DEPARTMENT_INPUT_Y = 685;
|
await button.click({ force: true });
|
||||||
const PROFILE_BLUR_X = 200;
|
await page.waitForTimeout(200);
|
||||||
const PROFILE_BLUR_Y = 260;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileCoords = {
|
||||||
|
departmentEditX: number;
|
||||||
|
departmentEditY: number;
|
||||||
|
departmentInputX: number;
|
||||||
|
departmentInputY: number;
|
||||||
|
blurX: number;
|
||||||
|
blurY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const desktopCoords: ProfileCoords = {
|
||||||
|
departmentEditX: 1170,
|
||||||
|
departmentEditY: 680,
|
||||||
|
departmentInputX: 110,
|
||||||
|
departmentInputY: 685,
|
||||||
|
blurX: 200,
|
||||||
|
blurY: 260,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mobileCoords: ProfileCoords = {
|
||||||
|
departmentEditX: 350,
|
||||||
|
departmentEditY: 680,
|
||||||
|
departmentInputX: 110,
|
||||||
|
departmentInputY: 685,
|
||||||
|
blurX: 200,
|
||||||
|
blurY: 260,
|
||||||
|
};
|
||||||
|
|
||||||
|
function coordsFor(page: Page): ProfileCoords {
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMobileProject(page: Page): boolean {
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
return (viewport?.width ?? 1280) <= 500;
|
||||||
|
}
|
||||||
|
|
||||||
async function seedTokenLogin(page: Page): Promise<void> {
|
async function seedTokenLogin(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
@@ -32,26 +70,56 @@ async function fillAt(page: Page, x: number, y: number, value: string): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openDepartmentEditor(page: Page): Promise<void> {
|
async function openDepartmentEditor(page: Page): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await enableFlutterAccessibility(page);
|
||||||
|
await page
|
||||||
|
.getByRole('group', { name: '소속 QA' })
|
||||||
|
.getByRole('button', { name: '편집' })
|
||||||
|
.click({ force: true });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
await page.locator('flt-glass-pane').click({
|
await page.locator('flt-glass-pane').click({
|
||||||
position: { x: PROFILE_DEPARTMENT_EDIT_X, y: PROFILE_DEPARTMENT_EDIT_Y },
|
position: { x: coords.departmentEditX, y: coords.departmentEditY },
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function blurDepartmentEditor(page: Page): Promise<void> {
|
async function blurDepartmentEditor(page: Page): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await page.getByRole('textbox', { name: '소속' }).blur();
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
await page.locator('flt-glass-pane').click({
|
await page.locator('flt-glass-pane').click({
|
||||||
position: { x: PROFILE_BLUR_X, y: PROFILE_BLUR_Y },
|
position: { x: coords.blurX, y: coords.blurY },
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitDepartmentEditor(page: Page): Promise<void> {
|
async function submitDepartmentEditor(page: Page): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await page.getByRole('textbox', { name: '소속' }).press('Enter');
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fillDepartmentField(page: Page, value: string): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await page.getByRole('textbox', { name: '소속' }).fill(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
|
await fillAt(page, coords.departmentInputX, coords.departmentInputY, value);
|
||||||
|
}
|
||||||
|
|
||||||
async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
|
async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
|
||||||
await page.route('**/api/v1/**', async (route: Route) => {
|
await page.route('**/api/v1/**', async (route: Route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
@@ -174,7 +242,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
await waitForInitialProfileLoad(state);
|
await waitForInitialProfileLoad(state);
|
||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated');
|
await fillDepartmentField(page, 'QA-Updated');
|
||||||
await submitDepartmentEditor(page);
|
await submitDepartmentEditor(page);
|
||||||
|
|
||||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||||
@@ -201,7 +269,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
await waitForInitialProfileLoad(state);
|
await waitForInitialProfileLoad(state);
|
||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Repro');
|
await fillDepartmentField(page, 'QA-Repro');
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||||
@@ -228,7 +296,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
await waitForInitialProfileLoad(state);
|
await waitForInitialProfileLoad(state);
|
||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA');
|
await fillDepartmentField(page, 'QA');
|
||||||
await blurDepartmentEditor(page);
|
await blurDepartmentEditor(page);
|
||||||
|
|
||||||
expect(state.putBodies).toHaveLength(0);
|
expect(state.putBodies).toHaveLength(0);
|
||||||
@@ -246,7 +314,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
await waitForInitialProfileLoad(state);
|
await waitForInitialProfileLoad(state);
|
||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, '');
|
await fillDepartmentField(page, '');
|
||||||
await blurDepartmentEditor(page);
|
await blurDepartmentEditor(page);
|
||||||
|
|
||||||
expect(state.putBodies).toHaveLength(0);
|
expect(state.putBodies).toHaveLength(0);
|
||||||
@@ -265,7 +333,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
await waitForInitialProfileLoad(state);
|
await waitForInitialProfileLoad(state);
|
||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1');
|
await fillDepartmentField(page, 'QA-1');
|
||||||
await submitDepartmentEditor(page);
|
await submitDepartmentEditor(page);
|
||||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||||
|
|
||||||
@@ -274,7 +342,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
await page.waitForTimeout(1200);
|
await page.waitForTimeout(1200);
|
||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2');
|
await fillDepartmentField(page, 'QA-2');
|
||||||
await submitDepartmentEditor(page);
|
await submitDepartmentEditor(page);
|
||||||
await expect.poll(() => state.putBodies.length).toBe(2);
|
await expect.poll(() => state.putBodies.length).toBe(2);
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ class AuthTokenStoreBackend {
|
|||||||
AuthTokenStoreBackend({
|
AuthTokenStoreBackend({
|
||||||
required AuthTokenStorageTarget localTarget,
|
required AuthTokenStorageTarget localTarget,
|
||||||
required AuthTokenStorageTarget sessionTarget,
|
required AuthTokenStorageTarget sessionTarget,
|
||||||
}) : _targets = [
|
}) : _targets = [localTarget, sessionTarget, _MemoryStorageTarget()];
|
||||||
localTarget,
|
|
||||||
sessionTarget,
|
|
||||||
_MemoryStorageTarget(),
|
|
||||||
];
|
|
||||||
|
|
||||||
static const _tokenKey = 'baron_auth_token';
|
static const _tokenKey = 'baron_auth_token';
|
||||||
static const _providerKey = 'baron_auth_provider';
|
static const _providerKey = 'baron_auth_provider';
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
_tabController = TabController(length: 3, vsync: this, initialIndex: 0);
|
||||||
_tabController.addListener(_handleTabSelection);
|
_tabController.addListener(_handleTabSelection);
|
||||||
_drySendEnabled =
|
_drySendEnabled =
|
||||||
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handlePasswordReset() async {
|
Future<void> _handlePasswordReset() async {
|
||||||
|
if (_isLoading) return;
|
||||||
if (_formKey.currentState?.validate() != true) return;
|
if (_formKey.currentState?.validate() != true) return;
|
||||||
if ((_loginId == null || _loginId!.isEmpty) &&
|
if ((_loginId == null || _loginId!.isEmpty) &&
|
||||||
(_token == null || _token!.isEmpty)) {
|
(_token == null || _token!.isEmpty)) {
|
||||||
@@ -76,6 +77,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
bool isSuccess = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await AuthProxyService.completePasswordReset(
|
await AuthProxyService.completePasswordReset(
|
||||||
@@ -84,6 +86,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
newPassword: _passwordController.text,
|
newPassword: _passwordController.text,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
isSuccess = true;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ToastService.success(tr('msg.userfront.reset.success'));
|
ToastService.success(tr('msg.userfront.reset.success'));
|
||||||
context.go(buildLocalizedSigninPath(Uri.base));
|
context.go(buildLocalizedSigninPath(Uri.base));
|
||||||
@@ -98,7 +101,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted && !isSuccess) {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:logging/logging.dart';
|
|||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
import '../../../../core/notifiers/auth_notifier.dart';
|
import '../../../../core/notifiers/auth_notifier.dart';
|
||||||
import '../../../../core/i18n/locale_utils.dart';
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
import '../../../../core/services/auth_token_store.dart';
|
import '../../../../core/services/auth_token_store.dart';
|
||||||
import '../../../../core/ui/layout_breakpoints.dart';
|
import '../../../../core/ui/layout_breakpoints.dart';
|
||||||
import '../../../../core/ui/toast_service.dart';
|
import '../../../../core/ui/toast_service.dart';
|
||||||
@@ -54,10 +55,80 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
bool _showCurrentPassword = false;
|
bool _showCurrentPassword = false;
|
||||||
bool _showNewPassword = false;
|
bool _showNewPassword = false;
|
||||||
bool _showConfirmPassword = false;
|
bool _showConfirmPassword = false;
|
||||||
|
Map<String, dynamic>? _passwordPolicy;
|
||||||
|
bool _isPasswordPolicyLoading = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadPasswordPolicy();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPasswordPolicy() async {
|
||||||
|
setState(() {
|
||||||
|
_isPasswordPolicyLoading = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final policy = await AuthProxyService.fetchPasswordPolicy();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_passwordPolicy = policy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 정책 조회 실패 시 기본 검증 규칙 사용
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isPasswordPolicyLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildPasswordPolicyDescription() {
|
||||||
|
if (_isPasswordPolicyLoading) {
|
||||||
|
return tr('msg.userfront.signup.policy.loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
final minLength = (_passwordPolicy?['minLength'] as int?) ?? 12;
|
||||||
|
final minTypes = (_passwordPolicy?['minCharacterTypes'] as int?) ?? 0;
|
||||||
|
final requiresLower = _passwordPolicy?['lowercase'] ?? true;
|
||||||
|
final requiresUpper = _passwordPolicy?['uppercase'] ?? false;
|
||||||
|
final requiresNumber = _passwordPolicy?['number'] ?? true;
|
||||||
|
final requiresSymbol = _passwordPolicy?['nonAlphanumeric'] ?? true;
|
||||||
|
|
||||||
|
final parts = <String>[
|
||||||
|
tr(
|
||||||
|
'msg.userfront.signup.policy.min_length',
|
||||||
|
params: {'count': '$minLength'},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (minTypes > 0) {
|
||||||
|
parts.add(
|
||||||
|
tr(
|
||||||
|
'msg.userfront.signup.policy.min_types',
|
||||||
|
params: {'count': '$minTypes'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (requiresLower) {
|
||||||
|
parts.add(tr('msg.userfront.signup.policy.lowercase'));
|
||||||
|
}
|
||||||
|
if (requiresUpper) {
|
||||||
|
parts.add(tr('msg.userfront.signup.policy.uppercase'));
|
||||||
|
}
|
||||||
|
if (requiresNumber) {
|
||||||
|
parts.add(tr('msg.userfront.signup.policy.number'));
|
||||||
|
}
|
||||||
|
if (requiresSymbol) {
|
||||||
|
parts.add(tr('msg.userfront.signup.policy.symbol'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return tr(
|
||||||
|
'msg.userfront.signup.policy.summary',
|
||||||
|
params: {'rules': parts.join(", ")},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _debugLog(
|
void _debugLog(
|
||||||
@@ -267,6 +338,58 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final minLength = (_passwordPolicy?['minLength'] as int?) ?? 12;
|
||||||
|
final minTypes = (_passwordPolicy?['minCharacterTypes'] as int?) ?? 0;
|
||||||
|
final hasLower = RegExp(r'[a-z]').hasMatch(newPassword);
|
||||||
|
final hasUpper = RegExp(r'[A-Z]').hasMatch(newPassword);
|
||||||
|
final hasNumber = RegExp(r'[0-9]').hasMatch(newPassword);
|
||||||
|
final hasSymbol = RegExp(r'[\W_]').hasMatch(newPassword);
|
||||||
|
int typeCount = 0;
|
||||||
|
if (hasLower) typeCount++;
|
||||||
|
if (hasUpper) typeCount++;
|
||||||
|
if (hasNumber) typeCount++;
|
||||||
|
if (hasSymbol) typeCount++;
|
||||||
|
|
||||||
|
if (newPassword.length < minLength) {
|
||||||
|
setState(
|
||||||
|
() => _passwordError = tr(
|
||||||
|
'msg.userfront.reset.error.min_length',
|
||||||
|
params: {'count': '$minLength'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (minTypes > 0 && typeCount < minTypes) {
|
||||||
|
setState(
|
||||||
|
() => _passwordError = tr(
|
||||||
|
'msg.userfront.reset.error.min_types',
|
||||||
|
params: {'count': '$minTypes'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((_passwordPolicy?['lowercase'] ?? true) && !hasLower) {
|
||||||
|
setState(
|
||||||
|
() => _passwordError = tr('msg.userfront.reset.error.lowercase'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((_passwordPolicy?['uppercase'] ?? false) && !hasUpper) {
|
||||||
|
setState(
|
||||||
|
() => _passwordError = tr('msg.userfront.reset.error.uppercase'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((_passwordPolicy?['number'] ?? true) && !hasNumber) {
|
||||||
|
setState(() => _passwordError = tr('msg.userfront.reset.error.number'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((_passwordPolicy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
|
||||||
|
setState(() => _passwordError = tr('msg.userfront.reset.error.symbol'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (newPassword != confirmPassword) {
|
if (newPassword != confirmPassword) {
|
||||||
setState(
|
setState(
|
||||||
() => _passwordError = tr('msg.userfront.profile.password.mismatch'),
|
() => _passwordError = tr('msg.userfront.profile.password.mismatch'),
|
||||||
@@ -645,6 +768,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
}) {
|
}) {
|
||||||
final isEditing = _editingField == field;
|
final isEditing = _editingField == field;
|
||||||
final displayValue = value.isEmpty ? '-' : value;
|
final displayValue = value.isEmpty ? '-' : value;
|
||||||
|
final isCompact = MediaQuery.of(context).size.width < 640;
|
||||||
|
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
@@ -661,16 +785,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
|
|
||||||
final hasChanged = _hasFieldChanged(profile, field);
|
final hasChanged = _hasFieldChanged(profile, field);
|
||||||
|
|
||||||
return Column(
|
final inputField = TextField(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
key: Key('profile-$field-input'),
|
key: Key('profile-$field-input'),
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
||||||
@@ -686,10 +801,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
hintText: label,
|
hintText: label,
|
||||||
errorText: _fieldSaveError,
|
errorText: _fieldSaveError,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
final saveButton = ElevatedButton(
|
||||||
const SizedBox(width: 12),
|
|
||||||
ElevatedButton(
|
|
||||||
key: Key('profile-$field-save-button'),
|
key: Key('profile-$field-save-button'),
|
||||||
onPressed: isUpdating || !hasChanged || _isSavingField
|
onPressed: isUpdating || !hasChanged || _isSavingField
|
||||||
? null
|
? null
|
||||||
@@ -701,15 +814,33 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: Text(tr('ui.common.save')),
|
: Text(tr('ui.common.save')),
|
||||||
),
|
);
|
||||||
const SizedBox(width: 8),
|
final cancelButton = OutlinedButton(
|
||||||
OutlinedButton(
|
|
||||||
key: Key('profile-$field-cancel-button'),
|
key: Key('profile-$field-cancel-button'),
|
||||||
onPressed: isUpdating || _isSavingField
|
onPressed: isUpdating || _isSavingField
|
||||||
? null
|
? null
|
||||||
: () => _cancelEditing(profile),
|
: () => _cancelEditing(profile),
|
||||||
child: Text(tr('ui.common.cancel')),
|
child: Text(tr('ui.common.cancel')),
|
||||||
),
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (isCompact) ...[
|
||||||
|
inputField,
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(spacing: 8, runSpacing: 8, children: [saveButton, cancelButton]),
|
||||||
|
] else
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: inputField),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
saveButton,
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
cancelButton,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -853,6 +984,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
tr('msg.userfront.profile.password.subtitle'),
|
tr('msg.userfront.profile.password.subtitle'),
|
||||||
style: const TextStyle(color: Color(0xFF6B7280)),
|
style: const TextStyle(color: Color(0xFF6B7280)),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_buildPasswordPolicyDescription(),
|
||||||
|
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _currentPasswordController,
|
controller: _currentPasswordController,
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ void main() {
|
|||||||
group('AuthTokenStoreBackend', () {
|
group('AuthTokenStoreBackend', () {
|
||||||
test('local 저장소가 실패하면 session 저장소에서 토큰을 읽는다', () {
|
test('local 저장소가 실패하면 session 저장소에서 토큰을 읽는다', () {
|
||||||
final local = _FakeTarget(throwsOnRead: true);
|
final local = _FakeTarget(throwsOnRead: true);
|
||||||
final session = _FakeTarget(readSeed: {'baron_auth_token': 'session-jwt'});
|
final session = _FakeTarget(
|
||||||
|
readSeed: {'baron_auth_token': 'session-jwt'},
|
||||||
|
);
|
||||||
final store = AuthTokenStoreBackend(
|
final store = AuthTokenStoreBackend(
|
||||||
localTarget: local,
|
localTarget: local,
|
||||||
sessionTarget: session,
|
sessionTarget: session,
|
||||||
|
|||||||
Reference in New Issue
Block a user