1
0
forked from baron/baron-sso

린트 적용

This commit is contained in:
2026-02-12 10:39:47 +09:00
parent 21b9594de5
commit 74884f6616
65 changed files with 26389 additions and 1583 deletions

File diff suppressed because one or more lines are too long

View File

@@ -3,23 +3,23 @@ import AppLayout from "../components/layout/AppLayout";
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage"; import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage"; import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
import AuditLogsPage from "../features/audit/AuditLogsPage"; import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthPage from "../features/auth/AuthPage"; import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage";
import DashboardPage from "../features/dashboard/DashboardPage"; import DashboardPage from "../features/dashboard/DashboardPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import LoginPage from "../features/auth/LoginPage"; import TenantGroupAdminsTab from "../features/tenant-groups/routes/TenantGroupAdminsTab";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import TenantGroupCreatePage from "../features/tenant-groups/routes/TenantGroupCreatePage"; import TenantGroupCreatePage from "../features/tenant-groups/routes/TenantGroupCreatePage";
import TenantGroupDetailPage from "../features/tenant-groups/routes/TenantGroupDetailPage"; import TenantGroupDetailPage from "../features/tenant-groups/routes/TenantGroupDetailPage";
import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage"; import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage";
import TenantGroupProfileTab from "../features/tenant-groups/routes/TenantGroupProfileTab"; import TenantGroupProfileTab from "../features/tenant-groups/routes/TenantGroupProfileTab";
import TenantGroupTenantsTab from "../features/tenant-groups/routes/TenantGroupTenantsTab"; import TenantGroupTenantsTab from "../features/tenant-groups/routes/TenantGroupTenantsTab";
import TenantGroupAdminsTab from "../features/tenant-groups/routes/TenantGroupAdminsTab"; import TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage"; import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage"; import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage"; import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab";
import UserCreatePage from "../features/users/UserCreatePage"; import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage"; import UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage"; import UserListPage from "../features/users/UserListPage";

View File

@@ -4,134 +4,143 @@ import {
Key, Key,
KeyRound, KeyRound,
LayoutDashboard, LayoutDashboard,
LayoutGrid, LayoutGrid,
LogOut, LogOut,
Moon, Moon,
NotebookTabs, NotebookTabs,
Rocket, Rocket,
ShieldHalf, ShieldHalf,
Sun, Sun,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import RoleSwitcher from "./RoleSwitcher"; import RoleSwitcher from "./RoleSwitcher";
const navItems = [ const navItems = [
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard }, { label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
{ {
label: "ui.admin.nav.tenant_dashboard", label: "ui.admin.nav.tenant_dashboard",
to: "/dashboard", to: "/dashboard",
icon: ShieldHalf, icon: ShieldHalf,
}, },
{ label: "ui.admin.nav.tenant_groups", to: "/tenant-groups", icon: LayoutGrid }, {
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 }, label: "ui.admin.nav.tenant_groups",
{ label: "ui.admin.nav.users", to: "/users", icon: Users }, { label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key }, to: "/tenant-groups",
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs }, icon: LayoutGrid,
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, },
]; { label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
function AppLayout() { { label: "ui.admin.nav.users", to: "/users", icon: Users },
const navigate = useNavigate(); { label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
const [theme, setTheme] = useState<"light" | "dark">(() => { { label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
const stored = window.localStorage.getItem("admin_theme"); { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
return stored === "dark" ? "dark" : "light"; ];
}); function AppLayout() {
const navigate = useNavigate();
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
const handleLogout = () => { const handleLogout = () => {
if (window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))) { if (
window.localStorage.removeItem("admin_session"); window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))
navigate("/login"); ) {
} window.localStorage.removeItem("admin_session");
}; navigate("/login");
}
};
useEffect(() => { useEffect(() => {
const session = window.localStorage.getItem("admin_session"); const session = window.localStorage.getItem("admin_session");
if (!session) { if (!session) {
navigate("/login"); navigate("/login");
} }
}, [navigate]); }, [navigate]);
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
root.classList.remove("light", "dark"); root.classList.remove("light", "dark");
if (theme === "light") { if (theme === "light") {
root.classList.add("light"); root.classList.add("light");
} else { } else {
root.classList.add("dark"); root.classList.add("dark");
} }
window.localStorage.setItem("admin_theme", theme); window.localStorage.setItem("admin_theme", theme);
}, [theme]); }, [theme]);
const toggleTheme = () => { const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light")); setTheme((prev) => (prev === "light" ? "dark" : "light"));
}; };
return ( return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]"> <div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur"> <aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6"> <div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start"> <div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]"> <div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<ShieldHalf size={20} /> <ShieldHalf size={20} />
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t("ui.admin.brand", "Baron 로그인")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.admin.title", "Admin Control")}
</h1>
</div>
</div> </div>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2"> <div>
<BadgeCheck size={14} /> <p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t("msg.admin.scope_admin", "Scoped to /admin")} {t("ui.admin.brand", "Baron 로그인")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.admin.title", "Admin Control")}
</h1>
</div> </div>
</div> </div>
<nav className="px-2 pb-4 md:px-3 md:pb-8 h-[calc(100vh-200px)] flex flex-col justify-between"> <div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<div className="space-y-1"> <BadgeCheck size={14} />
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start"> {t("msg.admin.scope_admin", "Scoped to /admin")}
<span className="rounded-full border border-border px-3 py-1"> </div>
{t("msg.admin.idp_env_prod", "IDP env: prod")} </div>
</span> <nav className="px-2 pb-4 md:px-3 md:pb-8 h-[calc(100vh-200px)] flex flex-col justify-between">
<span className="rounded-full border border-border px-3 py-1"> <div className="space-y-1">
{t("msg.admin.tenant_headers", "Tenant-aware headers")} <div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
</span> <span className="rounded-full border border-border px-3 py-1">
</div> {t("msg.admin.idp_env_prod", "IDP env: prod")}
<div className="flex flex-col gap-1"> </span>
{navItems.map(({ label, to, icon: Icon }) => ( <span className="rounded-full border border-border px-3 py-1">
<NavLink {t("msg.admin.tenant_headers", "Tenant-aware headers")}
key={to} </span>
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{t(label, label)}</span>
</NavLink>
))}
</div>
</div> </div>
<div className="flex flex-col gap-1">
{navItems.map(({ label, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{t(label, label)}</span>
</NavLink>
))}
</div>
</div>
<div className="px-3 pt-4 border-t border-border/50"> <div className="px-3 pt-4 border-t border-border/50">
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}
className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive" className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
> >
<LogOut size={18} /> <LogOut size={18} />
<span>{t("ui.admin.nav.logout", "Logout")}</span> <span>{t("ui.admin.nav.logout", "Logout")}</span>
</button> </button>
</div> </div>
</nav> </nav>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block"> <p> <div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
{" "}
<p>
{t( {t(
"msg.admin.notice.scope", "msg.admin.notice.scope",
"관리 기능은 /admin 네임스페이스에서만 노출합니다.", "관리 기능은 /admin 네임스페이스에서만 노출합니다.",

View File

@@ -26,7 +26,8 @@ const badgeVariants = cva(
); );
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {} VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {

View File

@@ -34,7 +34,8 @@ const buttonVariants = cva(
); );
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean; asChild?: boolean;
} }

View File

@@ -1,8 +1,7 @@
import * as React from "react"; import * as React from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export interface InputProps export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {

View File

@@ -1,8 +1,7 @@
import * as React from "react"; import * as React from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export interface TextareaProps export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {

View File

@@ -1,6 +1,6 @@
import { ShieldHalf } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { ShieldHalf } from "lucide-react";
function AuthCallbackPage() { function AuthCallbackPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -32,7 +32,9 @@ function AuthCallbackPage() {
<ShieldHalf size={32} /> <ShieldHalf size={32} />
</div> </div>
<div className="text-lg font-semibold"> ...</div> <div className="text-lg font-semibold"> ...</div>
<p className="text-sm text-muted-foreground"> .</p> <p className="text-sm text-muted-foreground">
.
</p>
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
import { ShieldHalf, LogIn, ExternalLink } from "lucide-react"; import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
@@ -44,7 +44,7 @@ function LoginPage() {
const popup = window.open( const popup = window.open(
loginUrl, loginUrl,
"BaronSSOLogin", "BaronSSOLogin",
`width=${width},height=${height},top=${top},left=${left},status=no,menubar=no,toolbar=no` `width=${width},height=${height},top=${top},left=${left},status=no,menubar=no,toolbar=no`,
); );
if (popup) { if (popup) {
@@ -78,8 +78,8 @@ function LoginPage() {
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl"> <Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
<CardTitle className="text-2xl flex items-center gap-2"> <CardTitle className="text-2xl flex items-center gap-2">
<LogIn size={20} className="text-primary" /> <LogIn size={20} className="text-primary" />
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Baron (SSO) . Baron (SSO) .
@@ -87,9 +87,9 @@ function LoginPage() {
</CardHeader> </CardHeader>
<CardContent className="pt-4 pb-8 space-y-3"> <CardContent className="pt-4 pb-8 space-y-3">
<Button <Button
onClick={handleSSOLogin} onClick={handleSSOLogin}
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg" className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
disabled={isLoggingIn} disabled={isLoggingIn}
> >
{isLoggingIn ? ( {isLoggingIn ? (
<> <>
@@ -106,20 +106,22 @@ function LoginPage() {
</Button> </Button>
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed"> <p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
15 .<br /> 15 .
<br />
. .
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<div className="flex justify-center gap-4"> <div className="flex justify-center gap-4">
<div className="h-1 w-1 rounded-full bg-primary/30"></div> <div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30"></div> <div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30"></div> <div className="h-1 w-1 rounded-full bg-primary/30" />
</div> </div>
<p className="px-8 text-center text-sm text-muted-foreground"> <p className="px-8 text-center text-sm text-muted-foreground">
<br />
<br />
. .
</p> </p>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { ShieldAlert, CheckCircle2, XCircle, Search } from "lucide-react"; import { CheckCircle2, Search, ShieldAlert, XCircle } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -31,9 +31,12 @@ function PermissionChecker() {
const checkMutation = useMutation({ const checkMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const { data } = await apiClient.get<CheckPermissionResponse>("/v1/admin/debug/check-permission", { const { data } = await apiClient.get<CheckPermissionResponse>(
params: { namespace, object, relation, subject }, "/v1/admin/debug/check-permission",
}); {
params: { namespace, object, relation, subject },
},
);
return data; return data;
}, },
}); });
@@ -48,7 +51,8 @@ function PermissionChecker() {
ReBAC ReBAC
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
(Subject) (Object) Ory Keto를 . (Subject) (Object) Ory
Keto를 .
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
@@ -56,74 +60,79 @@ function PermissionChecker() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Namespace</Label> <Label>Namespace</Label>
<select <select
value={namespace} value={namespace}
onChange={e => setNamespace(e.target.value)} onChange={(e) => setNamespace(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
> >
<option value="Tenant">Tenant</option> <option value="Tenant">Tenant</option>
<option value="TenantGroup">TenantGroup</option> <option value="TenantGroup">TenantGroup</option>
<option value="RelyingParty">RelyingParty</option> <option value="RelyingParty">RelyingParty</option>
<option value="System">System</option> <option value="System">System</option>
</select> </select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Relation</Label> <Label>Relation</Label>
<Input <Input
placeholder="view, manage, admins..." placeholder="view, manage, admins..."
value={relation} value={relation}
onChange={e => setRelation(e.target.value)} onChange={(e) => setRelation(e.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Object ID</Label> <Label>Object ID</Label>
<Input <Input
placeholder="Tenant UUID 등" placeholder="Tenant UUID 등"
value={object} value={object}
onChange={e => setObject(e.target.value)} onChange={(e) => setObject(e.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Subject (User:ID)</Label> <Label>Subject (User:ID)</Label>
<Input <Input
placeholder="User:uuid 또는 Namespace:ID#Relation" placeholder="User:uuid 또는 Namespace:ID#Relation"
value={subject} value={subject}
onChange={e => setSubject(e.target.value)} onChange={(e) => setSubject(e.target.value)}
/> />
</div> </div>
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center">
<Button <Button
onClick={() => checkMutation.mutate()} onClick={() => checkMutation.mutate()}
disabled={!object || !subject || checkMutation.isPending} disabled={!object || !subject || checkMutation.isPending}
className="w-full md:w-auto px-12" className="w-full md:w-auto px-12"
> >
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"} {checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
</Button> </Button>
</div> </div>
{checkMutation.isSuccess && ( {checkMutation.isSuccess && (
<div className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${ <div
result.allowed ? "bg-green-500/10 border-green-500/50 text-green-600" : "bg-destructive/10 border-destructive/50 text-destructive" className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
}`}> result.allowed
{result.allowed ? ( ? "bg-green-500/10 border-green-500/50 text-green-600"
<> : "bg-destructive/10 border-destructive/50 text-destructive"
<CheckCircle2 size={48} /> }`}
<div className="text-xl font-bold">Access ALLOWED</div> >
<p className="text-sm opacity-80 text-center"> {result.allowed ? (
. ( ) <>
</p> <CheckCircle2 size={48} />
</> <div className="text-xl font-bold">Access ALLOWED</div>
) : ( <p className="text-sm opacity-80 text-center">
<> . (
<XCircle size={48} /> )
<div className="text-xl font-bold">Access DENIED</div> </p>
<p className="text-sm opacity-80 text-center"> </>
. ) : (
</p> <>
</> <XCircle size={48} />
)} <div className="text-xl font-bold">Access DENIED</div>
</div> <p className="text-sm opacity-80 text-center">
.
</p>
</>
)}
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, ShieldCheck, Search, UserPlus } from "lucide-react"; import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
@@ -10,6 +10,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "../../../components/ui/card"; } from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { import {
Table, Table,
TableBody, TableBody,
@@ -18,13 +19,12 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import { Input } from "../../../components/ui/input";
import { import {
fetchGroupAdmins, type TenantGroupSummary,
addGroupAdmin, addGroupAdmin,
removeGroupAdmin, fetchGroupAdmins,
fetchUsers, fetchUsers,
type TenantGroupSummary removeGroupAdmin,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
function TenantGroupAdminsTab() { function TenantGroupAdminsTab() {
@@ -98,21 +98,26 @@ function TenantGroupAdminsTab() {
<TableBody> <TableBody>
{adminsQuery.data?.length === 0 && ( {adminsQuery.data?.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground"> <TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
. .
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{adminsQuery.data?.map((admin) => ( {adminsQuery.data?.map((admin) => (
<TableRow key={admin.id}> <TableRow key={admin.id}>
<TableCell className="font-medium">{admin.name || "Unknown"}</TableCell> <TableCell className="font-medium">
{admin.name || "Unknown"}
</TableCell>
<TableCell className="text-xs">{admin.email}</TableCell> <TableCell className="text-xs">{admin.email}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleRemoveAdmin(admin.id, admin.name)} onClick={() => handleRemoveAdmin(admin.id, admin.name)}
disabled={removeMutation.isPending} disabled={removeMutation.isPending}
> >
<Trash2 size={14} className="text-destructive" /> <Trash2 size={14} className="text-destructive" />
</Button> </Button>
@@ -129,8 +134,8 @@ function TenantGroupAdminsTab() {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<UserPlus size={18} className="text-primary" /> <UserPlus size={18} className="text-primary" />
</CardTitle> </CardTitle>
</div> </div>
<CardDescription> <CardDescription>
@@ -141,10 +146,10 @@ function TenantGroupAdminsTab() {
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="사용자 검색 (최소 2자)..." placeholder="사용자 검색 (최소 2자)..."
className="pl-10" className="pl-10"
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
</div> </div>
@@ -158,36 +163,47 @@ function TenantGroupAdminsTab() {
<TableBody> <TableBody>
{searchTerm.length < 2 && ( {searchTerm.length < 2 && (
<TableRow> <TableRow>
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground"> <TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
. .
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{searchTerm.length >= 2 && usersQuery.data?.items.length === 0 && ( {searchTerm.length >= 2 &&
<TableRow> usersQuery.data?.items.length === 0 && (
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground"> <TableRow>
. <TableCell
</TableCell> colSpan={2}
</TableRow> className="text-center py-8 text-muted-foreground"
)} >
{usersQuery.data?.items.filter(u => !adminsQuery.data?.some(a => a.id === u.id)).map((user) => ( .
<TableRow key={user.id}> </TableCell>
<TableCell> </TableRow>
<div className="font-medium">{user.name}</div> )}
<div className="text-[10px] text-muted-foreground">{user.email}</div> {usersQuery.data?.items
</TableCell> .filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
<TableCell className="text-right"> .map((user) => (
<Button <TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">
{user.email}
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleAddAdmin(user.id)} onClick={() => handleAddAdmin(user.id)}
disabled={addMutation.isPending} disabled={addMutation.isPending}
> >
<Plus size={14} /> <Plus size={14} />
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>

View File

@@ -88,7 +88,8 @@ function TenantGroupCreatePage() {
placeholder="baron-group" placeholder="baron-group"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
URL이나 API에서 . . URL이나 API에서 .
.
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -116,11 +117,12 @@ function TenantGroupCreatePage() {
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
.
.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="text-sm text-[var(--color-muted)]"> <CardContent className="text-sm text-[var(--color-muted)]">
. .
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -22,7 +22,10 @@ function TenantGroupDetailPage() {
<header className="flex flex-wrap items-start justify-between gap-4"> <header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]"> <div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/tenant-groups" className="inline-flex items-center gap-2 hover:text-foreground"> <Link
to="/tenant-groups"
className="inline-flex items-center gap-2 hover:text-foreground"
>
<ArrowLeft size={14} /> <ArrowLeft size={14} />
Groups Groups
</Link> </Link>
@@ -31,14 +34,15 @@ function TenantGroupDetailPage() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg"> <div className="p-2 bg-primary/10 rounded-lg">
<LayoutGrid size={24} className="text-primary" /> <LayoutGrid size={24} className="text-primary" />
</div> </div>
<h2 className="text-3xl font-semibold"> <h2 className="text-3xl font-semibold">
{groupQuery.data?.name ?? "Loading Group..."} {groupQuery.data?.name ?? "Loading Group..."}
</h2> </h2>
</div> </div>
<p className="text-sm text-[var(--color-muted)]"> <p className="text-sm text-[var(--color-muted)]">
{groupQuery.data?.description || "그룹 정보를 관리하고 소속 테넌트를 구성합니다."} {groupQuery.data?.description ||
"그룹 정보를 관리하고 소속 테넌트를 구성합니다."}
</p> </p>
</div> </div>
<Badge variant="muted">Super Admin only</Badge> <Badge variant="muted">Super Admin only</Badge>
@@ -79,7 +83,9 @@ function TenantGroupDetailPage() {
</div> </div>
<div className="mt-6"> <div className="mt-6">
<Outlet context={{ group: groupQuery.data, refetch: groupQuery.refetch }} /> <Outlet
context={{ group: groupQuery.data, refetch: groupQuery.refetch }}
/>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { Pencil, Plus, RefreshCw, Trash2, LayoutGrid } from "lucide-react"; import { LayoutGrid, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
@@ -85,8 +85,8 @@ function TenantGroupListPage() {
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<div> <div>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<LayoutGrid size={20} className="text-primary" /> <LayoutGrid size={20} className="text-primary" />
Tenant Group Registry Tenant Group Registry
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{query.data?.total ?? 0} {query.data?.total ?? 0}
@@ -130,7 +130,7 @@ function TenantGroupListPage() {
<TableCell>{group.slug}</TableCell> <TableCell>{group.slug}</TableCell>
<TableCell> <TableCell>
<Badge variant="secondary"> <Badge variant="secondary">
{group.tenants?.length ?? 0} {group.tenants?.length ?? 0}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -13,12 +13,15 @@ import {
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 { Textarea } from "../../../components/ui/textarea"; import { Textarea } from "../../../components/ui/textarea";
import { updateTenantGroup, type TenantGroupSummary } from "../../../lib/adminApi"; import {
type TenantGroupSummary,
updateTenantGroup,
} from "../../../lib/adminApi";
function TenantGroupProfileTab() { function TenantGroupProfileTab() {
const { group, refetch } = useOutletContext<{ const { group, refetch } = useOutletContext<{
group: TenantGroupSummary; group: TenantGroupSummary;
refetch: () => void refetch: () => void;
}>(); }>();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -44,7 +47,8 @@ function TenantGroupProfileTab() {
<CardHeader> <CardHeader>
<CardTitle> </CardTitle> <CardTitle> </CardTitle>
<CardDescription> <CardDescription>
. (Slug) . . (Slug)
.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -83,7 +87,10 @@ function TenantGroupProfileTab() {
<div className="flex justify-end pt-4"> <div className="flex justify-end pt-4">
<Button <Button
onClick={() => mutation.mutate()} onClick={() => mutation.mutate()}
disabled={mutation.isPending || (name === group.name && description === group.description)} disabled={
mutation.isPending ||
(name === group.name && description === group.description)
}
> >
{mutation.isPending ? "저장 중..." : "변경사항 저장"} {mutation.isPending ? "저장 중..." : "변경사항 저장"}
</Button> </Button>

View File

@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, Building2, Search } from "lucide-react"; import { Building2, Plus, Search, Trash2 } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Card, Card,
@@ -10,6 +11,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "../../../components/ui/card"; } from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { import {
Table, Table,
TableBody, TableBody,
@@ -18,19 +20,17 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import { Input } from "../../../components/ui/input";
import { Badge } from "../../../components/ui/badge";
import { import {
addTenantToGroup, type TenantGroupSummary,
removeTenantFromGroup, addTenantToGroup,
fetchTenants, fetchTenants,
type TenantGroupSummary removeTenantFromGroup,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
function TenantGroupTenantsTab() { function TenantGroupTenantsTab() {
const { group, refetch } = useOutletContext<{ const { group, refetch } = useOutletContext<{
group: TenantGroupSummary; group: TenantGroupSummary;
refetch: () => void refetch: () => void;
}>(); }>();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@@ -67,13 +67,15 @@ function TenantGroupTenantsTab() {
} }
}; };
const availableTenants = tenantsQuery.data?.items.filter( const availableTenants =
(t) => !group.tenants?.some((gt) => gt.id === t.id) tenantsQuery.data?.items.filter(
) || []; (t) => !group.tenants?.some((gt) => gt.id === t.id),
) || [];
const filteredAvailable = availableTenants.filter( const filteredAvailable = availableTenants.filter(
(t) => t.name.toLowerCase().includes(searchTerm.toLowerCase()) || (t) =>
t.slug.toLowerCase().includes(searchTerm.toLowerCase()) t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.slug.toLowerCase().includes(searchTerm.toLowerCase()),
); );
return ( return (
@@ -101,7 +103,10 @@ function TenantGroupTenantsTab() {
<TableBody> <TableBody>
{group.tenants?.length === 0 && ( {group.tenants?.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground"> <TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
. .
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -112,10 +117,10 @@ function TenantGroupTenantsTab() {
<TableCell className="text-xs">{t.slug}</TableCell> <TableCell className="text-xs">{t.slug}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleRemoveTenant(t.id)} onClick={() => handleRemoveTenant(t.id)}
disabled={removeMutation.isPending} disabled={removeMutation.isPending}
> >
<Trash2 size={14} className="text-destructive" /> <Trash2 size={14} className="text-destructive" />
</Button> </Button>
@@ -132,17 +137,17 @@ function TenantGroupTenantsTab() {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Plus size={18} className="text-primary" /> <Plus size={18} className="text-primary" />
</CardTitle> </CardTitle>
<div className="relative w-48"> <div className="relative w-48">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="검색..." placeholder="검색..."
className="pl-8 h-9" className="pl-8 h-9"
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
</div> </div>
</div> </div>
<CardDescription> <CardDescription>
@@ -161,7 +166,10 @@ function TenantGroupTenantsTab() {
<TableBody> <TableBody>
{filteredAvailable.length === 0 && ( {filteredAvailable.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground"> <TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
. .
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -170,19 +178,21 @@ function TenantGroupTenantsTab() {
<TableRow key={t.id}> <TableRow key={t.id}>
<TableCell> <TableCell>
<div className="font-medium">{t.name}</div> <div className="font-medium">{t.name}</div>
<div className="text-[10px] text-muted-foreground">{t.slug}</div> <div className="text-[10px] text-muted-foreground">
{t.slug}
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{t.status} {t.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleAddTenant(t.id)} onClick={() => handleAddTenant(t.id)}
disabled={addMutation.isPending} disabled={addMutation.isPending}
> >
<Plus size={14} /> <Plus size={14} />
</Button> </Button>

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, ShieldCheck, Search, UserPlus } from "lucide-react"; import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
@@ -10,6 +10,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "../../../components/ui/card"; } from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { import {
Table, Table,
TableBody, TableBody,
@@ -18,12 +19,11 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import { Input } from "../../../components/ui/input";
import { import {
fetchTenantAdmins, addTenantAdmin,
addTenantAdmin, fetchTenantAdmins,
removeTenantAdmin, fetchUsers,
fetchUsers removeTenantAdmin,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
function TenantAdminsTab() { function TenantAdminsTab() {
@@ -97,21 +97,26 @@ function TenantAdminsTab() {
<TableBody> <TableBody>
{adminsQuery.data?.length === 0 && ( {adminsQuery.data?.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground"> <TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
. .
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{adminsQuery.data?.map((admin) => ( {adminsQuery.data?.map((admin) => (
<TableRow key={admin.id}> <TableRow key={admin.id}>
<TableCell className="font-medium">{admin.name || "Unknown"}</TableCell> <TableCell className="font-medium">
{admin.name || "Unknown"}
</TableCell>
<TableCell className="text-xs">{admin.email}</TableCell> <TableCell className="text-xs">{admin.email}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleRemoveAdmin(admin.id, admin.name)} onClick={() => handleRemoveAdmin(admin.id, admin.name)}
disabled={removeMutation.isPending} disabled={removeMutation.isPending}
> >
<Trash2 size={14} className="text-destructive" /> <Trash2 size={14} className="text-destructive" />
</Button> </Button>
@@ -128,8 +133,8 @@ function TenantAdminsTab() {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<UserPlus size={18} className="text-primary" /> <UserPlus size={18} className="text-primary" />
</CardTitle> </CardTitle>
</div> </div>
<CardDescription> <CardDescription>
@@ -140,10 +145,10 @@ function TenantAdminsTab() {
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="사용자 검색 (최소 2자)..." placeholder="사용자 검색 (최소 2자)..."
className="pl-10" className="pl-10"
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
</div> </div>
@@ -157,36 +162,47 @@ function TenantAdminsTab() {
<TableBody> <TableBody>
{searchTerm.length < 2 && ( {searchTerm.length < 2 && (
<TableRow> <TableRow>
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground"> <TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
. .
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{searchTerm.length >= 2 && usersQuery.data?.items.length === 0 && ( {searchTerm.length >= 2 &&
<TableRow> usersQuery.data?.items.length === 0 && (
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground"> <TableRow>
. <TableCell
</TableCell> colSpan={2}
</TableRow> className="text-center py-8 text-muted-foreground"
)} >
{usersQuery.data?.items.filter(u => !adminsQuery.data?.some(a => a.id === u.id)).map((user) => ( .
<TableRow key={user.id}> </TableCell>
<TableCell> </TableRow>
<div className="font-medium">{user.name}</div> )}
<div className="text-[10px] text-muted-foreground">{user.email}</div> {usersQuery.data?.items
</TableCell> .filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
<TableCell className="text-right"> .map((user) => (
<Button <TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">
{user.email}
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleAddAdmin(user.id)} onClick={() => handleAddAdmin(user.id)}
disabled={addMutation.isPending} disabled={addMutation.isPending}
> >
<Plus size={14} /> <Plus size={14} />
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>

View File

@@ -45,7 +45,9 @@ function TenantDetailPage() {
<Link <Link
to={`/tenants/${tenantId}`} to={`/tenants/${tenantId}`}
className={`px-4 py-2 text-sm font-medium ${ className={`px-4 py-2 text-sm font-medium ${
!isFederationTab && !isAdminTab && !location.pathname.includes("/schema") !isFederationTab &&
!isAdminTab &&
!location.pathname.includes("/schema")
? "border-b-2 border-blue-500 text-blue-600" ? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700" : "text-gray-500 hover:text-gray-700"
}`} }`}

View File

@@ -160,7 +160,8 @@ export function TenantProfilePage() {
))} ))}
</select> </select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
. . .
.
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -54,7 +54,8 @@
body { body {
@apply min-h-screen bg-background font-sans text-foreground antialiased; @apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image: radial-gradient( background-image:
radial-gradient(
circle at 10% 18%, circle at 10% 18%,
rgba(54, 211, 153, 0.16), rgba(54, 211, 153, 0.16),
transparent 28% transparent 28%

View File

@@ -278,7 +278,9 @@ export async function deleteTenantGroup(id: string) {
} }
export async function addTenantToGroup(groupId: string, tenantId: string) { export async function addTenantToGroup(groupId: string, tenantId: string) {
await apiClient.post(`/v1/admin/tenant-groups/${groupId}/tenants/${tenantId}`); await apiClient.post(
`/v1/admin/tenant-groups/${groupId}/tenants/${tenantId}`,
);
} }
export async function removeTenantFromGroup(groupId: string, tenantId: string) { export async function removeTenantFromGroup(groupId: string, tenantId: string) {
@@ -326,9 +328,7 @@ export async function addGroupAdmin(groupId: string, userId: string) {
} }
export async function removeGroupAdmin(groupId: string, userId: string) { export async function removeGroupAdmin(groupId: string, userId: string) {
await apiClient.delete( await apiClient.delete(`/v1/admin/tenant-groups/${groupId}/admins/${userId}`);
`/v1/admin/tenant-groups/${groupId}/admins/${userId}`,
);
} }
// API Key Management (M2M) // API Key Management (M2M)
@@ -509,15 +509,10 @@ export async function updateRelyingParty(id: string, payload: HydraClientReq) {
} }
export async function deleteRelyingParty(id: string) { export async function deleteRelyingParty(id: string) {
await apiClient.delete(`/v1/admin/relying-parties/${id}`); await apiClient.delete(`/v1/admin/relying-parties/${id}`);
} }
export type RPOwner = { export type RPOwner = {
subject: string; subject: string;
name?: string; name?: string;
@@ -525,39 +520,24 @@ export type RPOwner = {
email?: string; email?: string;
type: string; type: string;
}; };
export async function fetchRPOwners(clientId: string) { export async function fetchRPOwners(clientId: string) {
const { data } = await apiClient.get<RPOwner[]>( const { data } = await apiClient.get<RPOwner[]>(
`/v1/admin/relying-parties/${clientId}/owners`, `/v1/admin/relying-parties/${clientId}/owners`,
); );
return data; return data;
} }
export async function addRPOwner(clientId: string, subject: string) { export async function addRPOwner(clientId: string, subject: string) {
await apiClient.post(
await apiClient.post(`/v1/admin/relying-parties/${clientId}/owners/${subject}`); `/v1/admin/relying-parties/${clientId}/owners/${subject}`,
);
} }
export async function removeRPOwner(clientId: string, subject: string) { export async function removeRPOwner(clientId: string, subject: string) {
await apiClient.delete( await apiClient.delete(
`/v1/admin/relying-parties/${clientId}/owners/${subject}`, `/v1/admin/relying-parties/${clientId}/owners/${subject}`,
); );
} }

View File

@@ -68,19 +68,19 @@ type SignupRequest struct {
// User Profile Models // User Profile Models
type UserProfileResponse struct { type UserProfileResponse struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name"` Name string `json:"name"`
Phone string `json:"phone"` Phone string `json:"phone"`
Role string `json:"role"` // 추가 Role string `json:"role"` // 추가
Department string `json:"department"` Department string `json:"department"`
AffiliationType string `json:"affiliationType"` AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"` CompanyCode string `json:"companyCode,omitempty"`
TenantID *string `json:"tenantId,omitempty"` // 추가 TenantID *string `json:"tenantId,omitempty"` // 추가
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"` Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"` Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록 ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
} }
type UpdateUserRequest struct { type UpdateUserRequest struct {

View File

@@ -23,13 +23,13 @@ type Tenant struct {
TenantGroup *TenantGroup `gorm:"foreignKey:TenantGroupID" json:"tenantGroup,omitempty"` TenantGroup *TenantGroup `gorm:"foreignKey:TenantGroupID" json:"tenantGroup,omitempty"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Slug string `gorm:"uniqueIndex;not null" json:"slug"` Slug string `gorm:"uniqueIndex;not null" json:"slug"`
Description string `json:"description"` Description string `json:"description"`
Status string `gorm:"default:'pending'" json:"status"` Status string `gorm:"default:'pending'" json:"status"`
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"` Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"` Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
} }
func (t *Tenant) IsActive() bool { func (t *Tenant) IsActive() bool {

View File

@@ -23,42 +23,52 @@ func (m *MockRPService) Create(ctx context.Context, tenantID string, client doma
args := m.Called(ctx, tenantID, client) args := m.Called(ctx, tenantID, client)
return args.Get(0).(*domain.RelyingParty), args.Error(1) return args.Get(0).(*domain.RelyingParty), args.Error(1)
} }
func (m *MockRPService) Get(ctx context.Context, clientID string) (*domain.RelyingParty, *domain.HydraClient, error) { func (m *MockRPService) Get(ctx context.Context, clientID string) (*domain.RelyingParty, *domain.HydraClient, error) {
args := m.Called(ctx, clientID) args := m.Called(ctx, clientID)
return args.Get(0).(*domain.RelyingParty), args.Get(1).(*domain.HydraClient), args.Error(2) return args.Get(0).(*domain.RelyingParty), args.Get(1).(*domain.HydraClient), args.Error(2)
} }
func (m *MockRPService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) { func (m *MockRPService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) {
args := m.Called(ctx, tenantID) args := m.Called(ctx, tenantID)
return args.Get(0).([]domain.RelyingParty), args.Error(1) return args.Get(0).([]domain.RelyingParty), args.Error(1)
} }
func (m *MockRPService) ListAll(ctx context.Context) ([]domain.RelyingParty, error) { func (m *MockRPService) ListAll(ctx context.Context) ([]domain.RelyingParty, error) {
args := m.Called(ctx) args := m.Called(ctx)
return args.Get(0).([]domain.RelyingParty), args.Error(1) return args.Get(0).([]domain.RelyingParty), args.Error(1)
} }
func (m *MockRPService) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) { func (m *MockRPService) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) {
args := m.Called(ctx, tenantIDs) args := m.Called(ctx, tenantIDs)
return args.Get(0).([]domain.RelyingParty), args.Error(1) return args.Get(0).([]domain.RelyingParty), args.Error(1)
} }
func (m *MockRPService) Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) { func (m *MockRPService) Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) {
args := m.Called(ctx, clientID, client) args := m.Called(ctx, clientID, client)
return args.Get(0).(*domain.RelyingParty), args.Error(1) return args.Get(0).(*domain.RelyingParty), args.Error(1)
} }
func (m *MockRPService) Delete(ctx context.Context, clientID string) error { func (m *MockRPService) Delete(ctx context.Context, clientID string) error {
args := m.Called(ctx, clientID) args := m.Called(ctx, clientID)
return args.Error(0) return args.Error(0)
} }
func (m *MockRPService) CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error) { func (m *MockRPService) CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error) {
args := m.Called(ctx, userID, clientID, relation) args := m.Called(ctx, userID, clientID, relation)
return args.Bool(0), args.Error(1) return args.Bool(0), args.Error(1)
} }
func (m *MockRPService) AddOwner(ctx context.Context, clientID, subject string) error { func (m *MockRPService) AddOwner(ctx context.Context, clientID, subject string) error {
args := m.Called(ctx, clientID, subject) args := m.Called(ctx, clientID, subject)
return args.Error(0) return args.Error(0)
} }
func (m *MockRPService) RemoveOwner(ctx context.Context, clientID, subject string) error { func (m *MockRPService) RemoveOwner(ctx context.Context, clientID, subject string) error {
args := m.Called(ctx, clientID, subject) args := m.Called(ctx, clientID, subject)
return args.Error(0) return args.Error(0)
} }
func (m *MockRPService) ListOwners(ctx context.Context, clientID string) ([]string, error) { func (m *MockRPService) ListOwners(ctx context.Context, clientID string) ([]string, error) {
args := m.Called(ctx, clientID) args := m.Called(ctx, clientID)
return args.Get(0).([]string), args.Error(1) return args.Get(0).([]string), args.Error(1)

View File

@@ -18,14 +18,14 @@ func NewTenantGroupHandler(svc service.TenantGroupService, userSvc *service.Krat
} }
type tenantGroupSummary struct { type tenantGroupSummary struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Slug string `json:"slug"` Slug string `json:"slug"`
Description string `json:"description"` Description string `json:"description"`
Tenants []tenantSummary `json:"tenants,omitempty"` Tenants []tenantSummary `json:"tenants,omitempty"`
Config domain.JSONMap `json:"config,omitempty"` Config domain.JSONMap `json:"config,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
} }
func (h *TenantGroupHandler) ListGroups(c *fiber.Ctx) error { func (h *TenantGroupHandler) ListGroups(c *fiber.Ctx) error {

View File

@@ -23,16 +23,20 @@ func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespac
args := m.Called(ctx, subject, namespace, object, relation) args := m.Called(ctx, subject, namespace, object, relation)
return args.Bool(0), args.Error(1) return args.Bool(0), args.Error(1)
} }
func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0) return m.Called(ctx, namespace, object, relation, subject).Error(0)
} }
func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0) return m.Called(ctx, namespace, object, relation, subject).Error(0)
} }
func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) { func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
args := m.Called(ctx, namespace, object, relation, subject) args := m.Called(ctx, namespace, object, relation, subject)
return args.Get(0).([]service.RelationTuple), args.Error(1) return args.Get(0).([]service.RelationTuple), args.Error(1)
} }
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) { func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject) args := m.Called(ctx, namespace, relation, subject)
return args.Get(0).([]string), args.Error(1) return args.Get(0).([]string), args.Error(1)

View File

@@ -54,6 +54,14 @@ func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object,
return args.Get(0).([]service.RelationTuple), args.Error(1) return args.Get(0).([]service.RelationTuple), args.Error(1)
} }
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]string), args.Error(1)
}
// Fixed MockKetoService to match service.KetoService exactly if possible. // Fixed MockKetoService to match service.KetoService exactly if possible.
// Wait, middleware/rbac.go imports baron-sso-backend/internal/service. // Wait, middleware/rbac.go imports baron-sso-backend/internal/service.
// So I should use service.RelationTuple. // So I should use service.RelationTuple.

View File

@@ -54,6 +54,14 @@ func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object,
return args.Get(0).([]RelationTuple), args.Error(1) return args.Get(0).([]RelationTuple), args.Error(1)
} }
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]string), args.Error(1)
}
// --- Test Helpers --- // --- Test Helpers ---
type hydraRoundTripperFunc func(*http.Request) (*http.Response, error) type hydraRoundTripperFunc func(*http.Request) (*http.Response, error)

View File

@@ -17,9 +17,11 @@ type MockTenantRepository struct {
func (m *MockTenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error { func (m *MockTenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error {
return m.Called(ctx, tenant).Error(0) return m.Called(ctx, tenant).Error(0)
} }
func (m *MockTenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error { func (m *MockTenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error {
return m.Called(ctx, tenant).Error(0) return m.Called(ctx, tenant).Error(0)
} }
func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) { func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
args := m.Called(ctx, id) args := m.Called(ctx, id)
if args.Get(0) == nil { if args.Get(0) == nil {
@@ -27,50 +29,31 @@ func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain
} }
return args.Get(0).(*domain.Tenant), args.Error(1) return args.Get(0).(*domain.Tenant), args.Error(1)
} }
func (m *MockTenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { func (m *MockTenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
args := m.Called(ctx, slug) args := m.Called(ctx, slug)
return args.Get(0).(*domain.Tenant), args.Error(1) return args.Get(0).(*domain.Tenant), args.Error(1)
} }
func (m *MockTenantRepository) FindByName(ctx context.Context, name string) (*domain.Tenant, error) { func (m *MockTenantRepository) FindByName(ctx context.Context, name string) (*domain.Tenant, error) {
args := m.Called(ctx, name) args := m.Called(ctx, name)
return args.Get(0).(*domain.Tenant), args.Error(1) return args.Get(0).(*domain.Tenant), args.Error(1)
} }
func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
args := m.Called(ctx, domainName) args := m.Called(ctx, domainName)
return args.Get(0).(*domain.Tenant), args.Error(1) return args.Get(0).(*domain.Tenant), args.Error(1)
} }
func (m *MockTenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) { func (m *MockTenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
args := m.Called(ctx, ids) args := m.Called(ctx, ids)
return args.Get(0).([]domain.Tenant), args.Error(1) return args.Get(0).([]domain.Tenant), args.Error(1)
} }
func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string) error { func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string) error {
return m.Called(ctx, tenantID, domainName).Error(0) return m.Called(ctx, tenantID, domainName).Error(0)
} }
// MockKetoService is a mock implementation of KetoService
type MockKetoService struct {
mock.Mock
}
func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
args := m.Called(ctx, subject, namespace, object, relation)
return args.Bool(0), args.Error(1)
}
func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) {
args := m.Called(ctx, namespace, object, relation, subject)
return args.Get(0).([]RelationTuple), args.Error(1)
}
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
return args.Get(0).([]string), args.Error(1)
}
func TestTenantService_ListManageableTenants_Inheritance(t *testing.T) { func TestTenantService_ListManageableTenants_Inheritance(t *testing.T) {
mockRepo := new(MockTenantRepository) mockRepo := new(MockTenantRepository)
mockKeto := new(MockKetoService) mockKeto := new(MockKetoService)

View File

@@ -26,7 +26,8 @@ const badgeVariants = cva(
); );
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {} VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {

View File

@@ -34,7 +34,8 @@ const buttonVariants = cva(
); );
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean; asChild?: boolean;
} }

View File

@@ -1,8 +1,7 @@
import * as React from "react"; import * as React from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export interface InputProps export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {

View File

@@ -1,8 +1,7 @@
import * as React from "react"; import * as React from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export interface TextareaProps export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {

View File

@@ -54,7 +54,8 @@
body { body {
@apply min-h-screen bg-background font-sans text-foreground antialiased; @apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image: radial-gradient( background-image:
radial-gradient(
circle at 10% 18%, circle at 10% 18%,
rgba(54, 211, 153, 0.16), rgba(54, 211, 153, 0.16),
transparent 28% transparent 28%

View File

@@ -11,7 +11,8 @@ class AuditService {
return dotenv.env[key] ?? fallback; return dotenv.env[key] ?? fallback;
} }
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); static String get _baseUrl =>
_envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
static Future<void> logEvent({ static Future<void> logEvent({
required String userId, required String userId,

View File

@@ -19,10 +19,12 @@ class AuthProxyService {
// 배포 환경에서 $ 기호나 공백이 섞여 들어오는 경우를 방지하기 위해 정제합니다. // 배포 환경에서 $ 기호나 공백이 섞여 들어오는 경우를 방지하기 위해 정제합니다.
return rawUrl.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), ''); return rawUrl.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), '');
} }
static bool get _isProd { static bool get _isProd {
final env = _envOrDefault('APP_ENV', 'dev').toLowerCase(); final env = _envOrDefault('APP_ENV', 'dev').toLowerCase();
return env == 'prod' || env == 'production'; return env == 'prod' || env == 'production';
} }
static bool get isProdEnv => _isProd; static bool get isProdEnv => _isProd;
static bool _shouldSendDrySend(bool? drySend) { static bool _shouldSendDrySend(bool? drySend) {
if (_isProd) { if (_isProd) {
@@ -76,13 +78,14 @@ class AuthProxyService {
} }
} }
static Future<int> getSessionStatus({String? token, bool useCookie = false}) async { static Future<int> getSessionStatus({
String? token,
bool useCookie = false,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/user/me'); final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
try { try {
final headers = <String, String>{ final headers = <String, String>{'Content-Type': 'application/json'};
'Content-Type': 'application/json',
};
if (!useCookie && token != null && token.isNotEmpty) { if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }
@@ -102,10 +105,7 @@ class AuthProxyService {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init'); final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr'); final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
final body = <String, dynamic>{ final body = <String, dynamic>{'loginId': loginId, 'uri': userfrontUrl};
'loginId': loginId,
'uri': userfrontUrl,
};
if (_shouldSendDrySend(drySend)) { if (_shouldSendDrySend(drySend)) {
body['drySend'] = true; body['drySend'] = true;
} }
@@ -133,15 +133,15 @@ class AuthProxyService {
} }
} }
static Future<Map<String, dynamic>> pollEnchantedLink(String pendingRef) async { static Future<Map<String, dynamic>> pollEnchantedLink(
String pendingRef,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/poll'); final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/poll');
final response = await http.post( final response = await http.post(
url, url,
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({'pendingRef': pendingRef}),
'pendingRef': pendingRef,
}),
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -157,16 +157,16 @@ class AuthProxyService {
); );
} }
static Future<Map<String, dynamic>> verifyMagicLink(String token, {bool verifyOnly = false}) async { static Future<Map<String, dynamic>> verifyMagicLink(
String token, {
bool verifyOnly = false,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify'); final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify');
final response = await http.post( final response = await http.post(
url, url,
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({'token': token, 'verifyOnly': verifyOnly}),
'token': token,
'verifyOnly': verifyOnly,
}),
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -223,10 +223,7 @@ class AuthProxyService {
final response = await http.post( final response = await http.post(
url, url,
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({'shortCode': shortCode, 'verifyOnly': verifyOnly}),
'shortCode': shortCode,
'verifyOnly': verifyOnly,
}),
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -240,13 +237,18 @@ class AuthProxyService {
} }
} }
static Future<Map<String, dynamic>> loginWithPassword(String loginId, String password, {String? loginChallenge}) async { static Future<Map<String, dynamic>> loginWithPassword(
String loginId,
String password, {
String? loginChallenge,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/login'); final url = Uri.parse('$_baseUrl/api/v1/auth/password/login');
final payload = { final payload = {
'loginId': loginId, 'loginId': loginId,
'password': password, 'password': password,
if (loginChallenge != null && loginChallenge.isNotEmpty) 'login_challenge': loginChallenge, if (loginChallenge != null && loginChallenge.isNotEmpty)
'login_challenge': loginChallenge,
}; };
final response = await http.post( final response = await http.post(
@@ -272,8 +274,13 @@ class AuthProxyService {
); );
} }
} }
static Future<Map<String, dynamic>> getConsentInfo(String consentChallenge) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/consent').replace(queryParameters: {'consent_challenge': consentChallenge}); static Future<Map<String, dynamic>> getConsentInfo(
String consentChallenge,
) async {
final url = Uri.parse(
'$_baseUrl/api/v1/auth/consent',
).replace(queryParameters: {'consent_challenge': consentChallenge});
final response = await http.get( final response = await http.get(
url, url,
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
@@ -293,11 +300,12 @@ class AuthProxyService {
} }
} }
static Future<Map<String, dynamic>> acceptConsent(String consentChallenge, {List<String>? grantScope}) async { static Future<Map<String, dynamic>> acceptConsent(
String consentChallenge, {
List<String>? grantScope,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/accept'); final url = Uri.parse('$_baseUrl/api/v1/auth/consent/accept');
final body = <String, dynamic>{ final body = <String, dynamic>{'consent_challenge': consentChallenge};
'consent_challenge': consentChallenge,
};
if (grantScope != null) { if (grantScope != null) {
body['grant_scope'] = grantScope; body['grant_scope'] = grantScope;
} }
@@ -322,11 +330,11 @@ class AuthProxyService {
} }
} }
static Future<Map<String, dynamic>> rejectConsent(String consentChallenge) async { static Future<Map<String, dynamic>> rejectConsent(
String consentChallenge,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/reject'); final url = Uri.parse('$_baseUrl/api/v1/auth/consent/reject');
final body = <String, dynamic>{ final body = <String, dynamic>{'consent_challenge': consentChallenge};
'consent_challenge': consentChallenge,
};
final response = await http.post( final response = await http.post(
url, url,
@@ -353,9 +361,7 @@ class AuthProxyService {
String? token, String? token,
}) async { }) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/oidc/login/accept'); final url = Uri.parse('$_baseUrl/api/v1/auth/oidc/login/accept');
final headers = <String, String>{ final headers = <String, String>{'Content-Type': 'application/json'};
'Content-Type': 'application/json',
};
if (token != null && token.isNotEmpty) { if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }
@@ -364,9 +370,7 @@ class AuthProxyService {
final response = await client.post( final response = await client.post(
url, url,
headers: headers, headers: headers,
body: jsonEncode({ body: jsonEncode({'login_challenge': loginChallenge}),
'login_challenge': loginChallenge,
}),
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -386,8 +390,10 @@ class AuthProxyService {
} }
} }
static Future<Map<String, dynamic>> initiatePasswordReset(
static Future<Map<String, dynamic>> initiatePasswordReset(String loginId, {bool? drySend}) async { String loginId, {
bool? drySend,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate'); final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
final response = await http.post( final response = await http.post(
url, url,
@@ -424,7 +430,9 @@ class AuthProxyService {
if (token != null && token.isNotEmpty) { if (token != null && token.isNotEmpty) {
query['token'] = token; query['token'] = token;
} }
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/complete').replace(queryParameters: query); final url = Uri.parse(
'$_baseUrl/api/v1/auth/password/reset/complete',
).replace(queryParameters: query);
final response = await http.post( final response = await http.post(
url, url,
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
@@ -451,9 +459,7 @@ class AuthProxyService {
final response = await http.post( final response = await http.post(
url, url,
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({'phoneNumber': phoneNumber}),
'phoneNumber': phoneNumber,
}),
); );
if (response.statusCode != 200) { if (response.statusCode != 200) {
@@ -465,16 +471,16 @@ class AuthProxyService {
} }
} }
static Future<Map<String, dynamic>> verifySmsCode(String phoneNumber, String code) async { static Future<Map<String, dynamic>> verifySmsCode(
String phoneNumber,
String code,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/verify-sms'); final url = Uri.parse('$_baseUrl/api/v1/auth/verify-sms');
final response = await http.post( final response = await http.post(
url, url,
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({'phoneNumber': phoneNumber, 'code': code}),
'phoneNumber': phoneNumber,
'code': code,
}),
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -532,10 +538,10 @@ class AuthProxyService {
String? token, String? token,
bool withCredentials = false, bool withCredentials = false,
}) async { }) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/approve'); // Mapping to ScanQRLogin on backend final url = Uri.parse(
final payload = <String, dynamic>{ '$_baseUrl/api/v1/auth/qr/approve',
'pendingRef': pendingRef, ); // Mapping to ScanQRLogin on backend
}; final payload = <String, dynamic>{'pendingRef': pendingRef};
if (token != null && token.isNotEmpty) { if (token != null && token.isNotEmpty) {
payload['token'] = token; payload['token'] = token;
} }
@@ -617,7 +623,10 @@ class AuthProxyService {
} }
} }
static Future<List<dynamic>> listUsers(String adminPassword, {String? query}) async { static Future<List<dynamic>> listUsers(
String adminPassword, {
String? query,
}) async {
var uri = Uri.parse('$_baseUrl/api/v1/admin/users'); var uri = Uri.parse('$_baseUrl/api/v1/admin/users');
if (query != null && query.isNotEmpty) { if (query != null && query.isNotEmpty) {
uri = uri.replace(queryParameters: {'text': query}); uri = uri.replace(queryParameters: {'text': query});
@@ -664,7 +673,11 @@ class AuthProxyService {
} }
} }
static Future<void> updateUserStatus(String adminPassword, String loginId, String status) async { static Future<void> updateUserStatus(
String adminPassword,
String loginId,
String status,
) async {
final encodedId = Uri.encodeComponent(loginId); final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId/status'); final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId/status');
@@ -725,18 +738,13 @@ class AuthProxyService {
final token = AuthTokenStore.getToken(); final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{ final headers = <String, String>{'Content-Type': 'application/json'};
'Content-Type': 'application/json',
};
if (!useCookie && token != null) { if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }
try { try {
final response = await client.get( final response = await client.get(url, headers: headers);
url,
headers: headers,
);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
@@ -758,18 +766,13 @@ class AuthProxyService {
final token = AuthTokenStore.getToken(); final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{ final headers = <String, String>{'Content-Type': 'application/json'};
'Content-Type': 'application/json',
};
if (!useCookie && token != null) { if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }
try { try {
final response = await client.delete( final response = await client.delete(url, headers: headers);
url,
headers: headers,
);
if (response.statusCode != 200) { if (response.statusCode != 200) {
final errorBody = jsonDecode(response.body); final errorBody = jsonDecode(response.body);
@@ -786,7 +789,11 @@ class AuthProxyService {
} }
} }
static Future<void> sendLog(String level, String message, {Map<String, dynamic>? data}) async { static Future<void> sendLog(
String level,
String message, {
Map<String, dynamic>? data,
}) async {
if (!_canSendClientLog()) { if (!_canSendClientLog()) {
return; return;
} }
@@ -808,7 +815,11 @@ class AuthProxyService {
} }
} }
static Future<void> logError(String message, {dynamic error, StackTrace? stackTrace}) async { static Future<void> logError(
String message, {
dynamic error,
StackTrace? stackTrace,
}) async {
final data = <String, dynamic>{}; final data = <String, dynamic>{};
if (error != null) data['error'] = error.toString(); if (error != null) data['error'] = error.toString();
if (stackTrace != null) data['stack'] = stackTrace.toString(); if (stackTrace != null) data['stack'] = stackTrace.toString();
@@ -877,17 +888,17 @@ class AuthProxyService {
} }
} }
static Future<bool> verifySignupCode(String target, String type, String code) async { static Future<bool> verifySignupCode(
String target,
String type,
String code,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/verify-code'); final url = Uri.parse('$_baseUrl/api/v1/auth/signup/verify-code');
final response = await http.post( final response = await http.post(
url, url,
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({'target': target, 'type': type, 'code': code}),
'target': target,
'type': type,
'code': code,
}),
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {

View File

@@ -1,6 +1,5 @@
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'http_client_stub.dart' import 'http_client_stub.dart' if (dart.library.html) 'http_client_web.dart';
if (dart.library.html) 'http_client_web.dart';
http.Client createHttpClient({bool withCredentials = false}) { http.Client createHttpClient({bool withCredentials = false}) {
return httpClientFactory.create(withCredentials: withCredentials); return httpClientFactory.create(withCredentials: withCredentials);

View File

@@ -25,7 +25,9 @@ class LoggerService {
); );
// 2. Configure Standard Logger (logging package) // 2. Configure Standard Logger (logging package)
std_log.Logger.root.level = kReleaseMode ? std_log.Level.WARNING : std_log.Level.ALL; std_log.Logger.root.level = kReleaseMode
? std_log.Level.WARNING
: std_log.Level.ALL;
std_log.Logger.root.onRecord.listen((record) { std_log.Logger.root.onRecord.listen((record) {
if (kReleaseMode) { if (kReleaseMode) {
@@ -47,7 +49,11 @@ class LoggerService {
void _logPretty(std_log.LogRecord record) { void _logPretty(std_log.LogRecord record) {
if (record.level >= std_log.Level.SEVERE) { if (record.level >= std_log.Level.SEVERE) {
_prettyLogger.e(record.message, error: record.error, stackTrace: record.stackTrace); _prettyLogger.e(
record.message,
error: record.error,
stackTrace: record.stackTrace,
);
} else if (record.level >= std_log.Level.WARNING) { } else if (record.level >= std_log.Level.WARNING) {
_prettyLogger.w(record.message); _prettyLogger.w(record.message);
} else if (record.level >= std_log.Level.INFO) { } else if (record.level >= std_log.Level.INFO) {

View File

@@ -15,14 +15,20 @@ void implSendLoginSuccess(String token) {
final uri = Uri.base; final uri = Uri.base;
// Try to find redirect_uri from standard parsing first, then manual string search // Try to find redirect_uri from standard parsing first, then manual string search
String? redirectUri = uri.queryParameters['redirect_uri'] ?? uri.queryParameters['redirect_url']; String? redirectUri =
uri.queryParameters['redirect_uri'] ??
uri.queryParameters['redirect_url'];
if (redirectUri == null) { if (redirectUri == null) {
// Manual fallback for cases where Uri.base misses params // Manual fallback for cases where Uri.base misses params
final searchParams = html.window.location.search; final searchParams = html.window.location.search;
if (searchParams != null && searchParams.isNotEmpty) { if (searchParams != null && searchParams.isNotEmpty) {
final sUri = Uri.parse('?${searchParams.startsWith('?') ? searchParams.substring(1) : searchParams}'); final sUri = Uri.parse(
redirectUri = sUri.queryParameters['redirect_uri'] ?? sUri.queryParameters['redirect_url']; '?${searchParams.startsWith('?') ? searchParams.substring(1) : searchParams}',
);
redirectUri =
sUri.queryParameters['redirect_uri'] ??
sUri.queryParameters['redirect_url'];
} }
} }
@@ -69,7 +75,7 @@ void implSendLoginSuccess(String token) {
html.window.close(); html.window.close();
}); });
} else { } else {
// Should not happen given isPopup check, but as fallback: // Should not happen given isPopup check, but as fallback:
debugPrint('No opener found during popup flow.'); debugPrint('No opener found during popup flow.');
} }
} }

View File

@@ -86,7 +86,10 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
} else { } else {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid Password. Access Denied.'), backgroundColor: Colors.red), const SnackBar(
content: Text('Invalid Password. Access Denied.'),
backgroundColor: Colors.red,
),
); );
context.go('/'); // Kick out context.go('/'); // Kick out
} }
@@ -116,7 +119,9 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
} }
} }
String? phone = _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim(); String? phone = _phoneController.text.trim().isEmpty
? null
: _phoneController.text.trim();
if (phone != null && !phone.contains('@')) { if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), ''); phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) { if (phone.startsWith('010')) {
@@ -128,14 +133,21 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
await AuthProxyService.createUser( await AuthProxyService.createUser(
loginId: loginId, loginId: loginId,
adminPassword: _verifiedAdminPassword!, adminPassword: _verifiedAdminPassword!,
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), email: _emailController.text.trim().isEmpty
? null
: _emailController.text.trim(),
phone: phone, phone: phone,
displayName: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(), displayName: _nameController.text.trim().isEmpty
? null
: _nameController.text.trim(),
); );
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('User created successfully!'), backgroundColor: Colors.green), const SnackBar(
content: Text('User created successfully!'),
backgroundColor: Colors.green,
),
); );
_formKey.currentState!.reset(); _formKey.currentState!.reset();
_loginIdController.clear(); _loginIdController.clear();
@@ -158,9 +170,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Hide content until authorized // Hide content until authorized
if (!_isAuthorized) { if (!_isAuthorized) {
return const Scaffold( return const Scaffold(body: Center(child: CircularProgressIndicator()));
body: Center(child: CircularProgressIndicator()),
);
} }
return Scaffold( return Scaffold(
@@ -194,7 +204,9 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
helperText: "Unique identifier (Email or Phone)", helperText: "Unique identifier (Email or Phone)",
), ),
validator: (value) => value == null || value.isEmpty ? 'Please enter Login ID' : null, validator: (value) => value == null || value.isEmpty
? 'Please enter Login ID'
: null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@@ -10,7 +10,8 @@ class UserManagementScreen extends StatefulWidget {
State<UserManagementScreen> createState() => _UserManagementScreenState(); State<UserManagementScreen> createState() => _UserManagementScreenState();
} }
class _UserManagementScreenState extends State<UserManagementScreen> with SingleTickerProviderStateMixin { class _UserManagementScreenState extends State<UserManagementScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
bool _isAuthorized = false; bool _isAuthorized = false;
String? _verifiedAdminPassword; String? _verifiedAdminPassword;
@@ -23,7 +24,8 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
// --- Create Tab Controllers --- // --- Create Tab Controllers ---
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final TextEditingController _createLoginIdController = TextEditingController(); final TextEditingController _createLoginIdController =
TextEditingController();
final TextEditingController _createEmailController = TextEditingController(); final TextEditingController _createEmailController = TextEditingController();
final TextEditingController _createPhoneController = TextEditingController(); final TextEditingController _createPhoneController = TextEditingController();
final TextEditingController _createNameController = TextEditingController(); final TextEditingController _createNameController = TextEditingController();
@@ -64,15 +66,24 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
TextField( TextField(
controller: passwordController, controller: passwordController,
obscureText: true, obscureText: true,
decoration: const InputDecoration(labelText: "Password", border: OutlineInputBorder()), decoration: const InputDecoration(
labelText: "Password",
border: OutlineInputBorder(),
),
autofocus: true, autofocus: true,
onSubmitted: (value) => Navigator.pop(context, value), onSubmitted: (value) => Navigator.pop(context, value),
), ),
], ],
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context, null), child: const Text("Cancel")), TextButton(
FilledButton(onPressed: () => Navigator.pop(context, passwordController.text), child: const Text("Enter")), onPressed: () => Navigator.pop(context, null),
child: const Text("Cancel"),
),
FilledButton(
onPressed: () => Navigator.pop(context, passwordController.text),
child: const Text("Enter"),
),
], ],
), ),
); );
@@ -96,7 +107,12 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
} }
} else { } else {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Invalid Password'), backgroundColor: Colors.red)); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid Password'),
backgroundColor: Colors.red,
),
);
context.go('/'); context.go('/');
} }
} }
@@ -107,7 +123,10 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
if (_verifiedAdminPassword == null) return; if (_verifiedAdminPassword == null) return;
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final users = await AuthProxyService.listUsers(_verifiedAdminPassword!, query: query); final users = await AuthProxyService.listUsers(
_verifiedAdminPassword!,
query: query,
);
setState(() => _users = users); setState(() => _users = users);
} catch (e) { } catch (e) {
_showError("Failed to load users: $e"); _showError("Failed to load users: $e");
@@ -130,13 +149,18 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text("Delete User"), title: const Text("Delete User"),
content: Text("Are you sure you want to delete $loginId? This cannot be undone."), content: Text(
"Are you sure you want to delete $loginId? This cannot be undone.",
),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")), TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text("Cancel"),
),
FilledButton( FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red), style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: const Text("Delete") child: const Text("Delete"),
), ),
], ],
), ),
@@ -158,11 +182,17 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
Future<void> _toggleStatus(String loginId, String currentStatus) async { Future<void> _toggleStatus(String loginId, String currentStatus) async {
if (_verifiedAdminPassword == null) return; if (_verifiedAdminPassword == null) return;
final newStatus = (currentStatus == "enabled" || currentStatus == "active") ? "disabled" : "enabled"; final newStatus = (currentStatus == "enabled" || currentStatus == "active")
? "disabled"
: "enabled";
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
await AuthProxyService.updateUserStatus(_verifiedAdminPassword!, loginId, newStatus); await AuthProxyService.updateUserStatus(
_verifiedAdminPassword!,
loginId,
newStatus,
);
_showSuccess("User status updated to $newStatus"); _showSuccess("User status updated to $newStatus");
_loadUsers(query: _searchController.text); _loadUsers(query: _searchController.text);
} catch (e) { } catch (e) {
@@ -179,9 +209,15 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : ""; final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "";
if (loginId.isEmpty) return; if (loginId.isEmpty) return;
final nameController = TextEditingController(text: user['name'] ?? user['user']?['name'] ?? ""); final nameController = TextEditingController(
final emailController = TextEditingController(text: user['user']?['email'] ?? ""); text: user['name'] ?? user['user']?['name'] ?? "",
final phoneController = TextEditingController(text: user['user']?['phone'] ?? ""); );
final emailController = TextEditingController(
text: user['user']?['email'] ?? "",
);
final phoneController = TextEditingController(
text: user['user']?['phone'] ?? "",
);
final confirm = await showDialog<bool>( final confirm = await showDialog<bool>(
context: context, context: context,
@@ -190,14 +226,29 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField(controller: nameController, decoration: const InputDecoration(labelText: "Name")), TextField(
TextField(controller: emailController, decoration: const InputDecoration(labelText: "Email")), controller: nameController,
TextField(controller: phoneController, decoration: const InputDecoration(labelText: "Phone")), decoration: const InputDecoration(labelText: "Name"),
),
TextField(
controller: emailController,
decoration: const InputDecoration(labelText: "Email"),
),
TextField(
controller: phoneController,
decoration: const InputDecoration(labelText: "Phone"),
),
], ],
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")), TextButton(
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text("Save")), onPressed: () => Navigator.pop(context, false),
child: const Text("Cancel"),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text("Save"),
),
], ],
), ),
); );
@@ -206,7 +257,9 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
setState(() => _isLoading = true); setState(() => _isLoading = true);
String? phone = phoneController.text.trim().isEmpty ? null : phoneController.text.trim(); String? phone = phoneController.text.trim().isEmpty
? null
: phoneController.text.trim();
if (phone != null && !phone.contains('@')) { if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), ''); phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) { if (phone.startsWith('010')) {
@@ -246,7 +299,9 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
} }
} }
String? phone = _createPhoneController.text.trim().isEmpty ? null : _createPhoneController.text.trim(); String? phone = _createPhoneController.text.trim().isEmpty
? null
: _createPhoneController.text.trim();
if (phone != null && !phone.contains('@')) { if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), ''); phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) { if (phone.startsWith('010')) {
@@ -258,9 +313,13 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
await AuthProxyService.createUser( await AuthProxyService.createUser(
loginId: loginId, loginId: loginId,
adminPassword: _verifiedAdminPassword!, adminPassword: _verifiedAdminPassword!,
email: _createEmailController.text.trim().isEmpty ? null : _createEmailController.text.trim(), email: _createEmailController.text.trim().isEmpty
? null
: _createEmailController.text.trim(),
phone: phone, phone: phone,
displayName: _createNameController.text.trim().isEmpty ? null : _createNameController.text.trim(), displayName: _createNameController.text.trim().isEmpty
? null
: _createNameController.text.trim(),
); );
_showSuccess("User created successfully"); _showSuccess("User created successfully");
@@ -273,7 +332,6 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
// Switch to list tab and reload // Switch to list tab and reload
_tabController.animateTo(0); _tabController.animateTo(0);
_loadUsers(); _loadUsers();
} catch (e) { } catch (e) {
_showError("Error: $e"); _showError("Error: $e");
} finally { } finally {
@@ -284,12 +342,16 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
// --- UI Helpers --- // --- UI Helpers ---
void _showError(String msg) { void _showError(String msg) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red)); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
} }
void _showSuccess(String msg) { void _showSuccess(String msg) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green)); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
} }
@override @override
@@ -315,10 +377,7 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
), ),
body: TabBarView( body: TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [_buildUserListTab(), _buildCreateUserTab()],
_buildUserListTab(),
_buildCreateUserTab(),
],
), ),
); );
} }
@@ -351,22 +410,39 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
// 일부 응답은 최상위 또는 user 하위에 필드를 포함합니다. // 일부 응답은 최상위 또는 user 하위에 필드를 포함합니다.
final loginIDs = (user['loginIds'] as List?) ?? []; final loginIDs = (user['loginIds'] as List?) ?? [];
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "Unknown ID"; final loginId = loginIDs.isNotEmpty
final name = user['name'] ?? user['user']?['name'] ?? "No Name"; ? loginIDs.first.toString()
: "Unknown ID";
final name =
user['name'] ?? user['user']?['name'] ?? "No Name";
final status = user['status'] ?? "unknown"; final status = user['status'] ?? "unknown";
final isEnabled = status == "enabled" || status == "active"; final isEnabled = status == "enabled" || status == "active";
return ListTile( return ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: isEnabled ? Colors.green.shade100 : Colors.grey.shade300, backgroundColor: isEnabled
child: Icon(Icons.person, color: isEnabled ? Colors.green : Colors.grey), ? Colors.green.shade100
: Colors.grey.shade300,
child: Icon(
Icons.person,
color: isEnabled ? Colors.green : Colors.grey,
),
), ),
title: Text(name), title: Text(name),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(loginId, style: const TextStyle(fontWeight: FontWeight.bold)), Text(
Text("Status: $status", style: TextStyle(color: isEnabled ? Colors.green : Colors.red, fontSize: 12)), loginId,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
"Status: $status",
style: TextStyle(
color: isEnabled ? Colors.green : Colors.red,
fontSize: 12,
),
),
], ],
), ),
trailing: Row( trailing: Row(
@@ -378,7 +454,10 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
onPressed: () => _editUser(user), onPressed: () => _editUser(user),
), ),
IconButton( IconButton(
icon: Icon(isEnabled ? Icons.block : Icons.check_circle, color: isEnabled ? Colors.orange : Colors.green), icon: Icon(
isEnabled ? Icons.block : Icons.check_circle,
color: isEnabled ? Colors.orange : Colors.green,
),
tooltip: isEnabled ? "Disable User" : "Enable User", tooltip: isEnabled ? "Disable User" : "Enable User",
onPressed: () => _toggleStatus(loginId, status), onPressed: () => _toggleStatus(loginId, status),
), ),
@@ -417,27 +496,44 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
border: OutlineInputBorder(), border: OutlineInputBorder(),
helperText: "Unique identifier (Email or Phone)", helperText: "Unique identifier (Email or Phone)",
), ),
validator: (value) => value == null || value.isEmpty ? 'Please enter Login ID' : null, validator: (value) => value == null || value.isEmpty
? 'Please enter Login ID'
: null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _createNameController, controller: _createNameController,
decoration: const InputDecoration(labelText: "Display Name", border: OutlineInputBorder(), prefixIcon: Icon(Icons.person)), decoration: const InputDecoration(
labelText: "Display Name",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _createEmailController, controller: _createEmailController,
decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder(), prefixIcon: Icon(Icons.email)), decoration: const InputDecoration(
labelText: "Email",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _createPhoneController, controller: _createPhoneController,
decoration: const InputDecoration(labelText: "Phone Number", border: OutlineInputBorder(), prefixIcon: Icon(Icons.phone), helperText: "010-xxxx-xxxx"), decoration: const InputDecoration(
labelText: "Phone Number",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
helperText: "010-xxxx-xxxx",
),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
FilledButton( FilledButton(
onPressed: _isLoading ? null : _createUserSubmit, onPressed: _isLoading ? null : _createUserSubmit,
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text("Create User"), child: const Text("Create User"),
), ),
], ],

View File

@@ -140,7 +140,10 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
padding: const EdgeInsets.only(bottom: 20), padding: const EdgeInsets.only(bottom: 20),
child: Text( child: Text(
_message!, _message!,
style: TextStyle(color: _success ? Colors.green : Colors.red, fontWeight: FontWeight.bold), style: TextStyle(
color: _success ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),

View File

@@ -41,7 +41,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
Future<void> _fetchConsentInfo() async { Future<void> _fetchConsentInfo() async {
try { try {
final info = await AuthProxyService.getConsentInfo(widget.consentChallenge); final info = await AuthProxyService.getConsentInfo(
widget.consentChallenge,
);
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동 // [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
if (info['redirectTo'] != null) { if (info['redirectTo'] != null) {
@@ -56,7 +58,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
details.forEach((scope, detail) { details.forEach((scope, detail) {
if (detail is Map<String, dynamic>) { if (detail is Map<String, dynamic>) {
// 설명 업데이트 // 설명 업데이트
if (detail['description'] != null && detail['description'].toString().isNotEmpty) { if (detail['description'] != null &&
detail['description'].toString().isNotEmpty) {
_scopeDescriptions[scope] = detail['description'].toString(); _scopeDescriptions[scope] = detail['description'].toString();
} }
// 필수 여부 업데이트 // 필수 여부 업데이트
@@ -76,7 +79,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
} }
// 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택 // 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택
final requestedScopes = (info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? []; final requestedScopes =
(info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
_selectedScopes.addAll(requestedScopes); _selectedScopes.addAll(requestedScopes);
setState(() { setState(() {
@@ -142,7 +146,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
if (confirmed == true) { if (confirmed == true) {
setState(() => _isSubmitting = true); setState(() => _isSubmitting = true);
try { try {
final resp = await AuthProxyService.rejectConsent(widget.consentChallenge); final resp = await AuthProxyService.rejectConsent(
widget.consentChallenge,
);
final redirectTo = resp['redirectTo']; final redirectTo = resp['redirectTo'];
if (redirectTo != null) { if (redirectTo != null) {
webWindow.redirectTo(redirectTo); webWindow.redirectTo(redirectTo);
@@ -152,9 +158,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
} catch (e) { } catch (e) {
setState(() => _isSubmitting = false); setState(() => _isSubmitting = false);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')), context,
); ).showSnackBar(SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')));
} }
} }
} }
@@ -169,8 +175,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
child: _isLoading child: _isLoading
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: _error != null : _error != null
? _buildErrorCard() ? _buildErrorCard()
: _buildConsentCard(context), : _buildConsentCard(context),
), ),
); );
} }
@@ -196,7 +202,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱'; final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱';
final clientId = _consentInfo?['client']?['client_id'] ?? '-'; final clientId = _consentInfo?['client']?['client_id'] ?? '-';
final clientLogo = _consentInfo?['client']?['logo_uri']; final clientLogo = _consentInfo?['client']?['logo_uri'];
final requestedScopes = (_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ?? []; final requestedScopes =
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
[];
return SingleChildScrollView( return SingleChildScrollView(
child: Container( child: Container(
@@ -204,7 +212,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
margin: const EdgeInsets.all(16), margin: const EdgeInsets.all(16),
child: Card( child: Card(
elevation: 8, elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),
child: Column( child: Column(
@@ -235,7 +245,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
), ),
child: Row( child: Row(
children: [ children: [
if (clientLogo != null && clientLogo.toString().isNotEmpty) if (clientLogo != null &&
clientLogo.toString().isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(right: 16), padding: const EdgeInsets.only(right: 16),
child: CircleAvatar( child: CircleAvatar(
@@ -286,7 +297,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
children: [ children: [
const Text( const Text(
'요청된 권한', '요청된 권한',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
), ),
Text( Text(
'${requestedScopes.length}', '${requestedScopes.length}',
@@ -354,7 +368,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
) )
: const Text( : const Text(
'동의하고 계속하기', '동의하고 계속하기',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@@ -32,30 +32,36 @@ class ErrorScreen extends StatelessWidget {
final title = isProd final title = isProd
? tr('msg.userfront.error.title', fallback: '인증 과정에서 오류가 발생했습니다') ? tr('msg.userfront.error.title', fallback: '인증 과정에서 오류가 발생했습니다')
: (hasCode : (hasCode
? tr( ? tr(
'msg.userfront.error.title_with_code', 'msg.userfront.error.title_with_code',
fallback: '오류: {{code}}', fallback: '오류: {{code}}',
params: {'code': normalizedCode}, params: {'code': normalizedCode},
) )
: tr('msg.userfront.error.title_generic', fallback: '오류가 발생했습니다')); : tr(
'msg.userfront.error.title_generic',
fallback: '오류가 발생했습니다',
));
final detail = isProd final detail = isProd
? (isWhitelisted ? (isWhitelisted
? tr( ? tr(
'msg.userfront.error.whitelist.$normalizedCode', 'msg.userfront.error.whitelist.$normalizedCode',
fallback: whitelistFallback, fallback: whitelistFallback,
) )
: tr( : tr(
'msg.userfront.error.detail_contact', 'msg.userfront.error.detail_contact',
fallback: '에러가 계속되면 관리자에게 문의해주세요', fallback: '에러가 계속되면 관리자에게 문의해주세요',
)) ))
: ((description?.isNotEmpty == true) : ((description?.isNotEmpty == true)
? description! ? description!
: (hasCode : (hasCode
? tr('msg.userfront.error.detail_generic', fallback: '오류가 발생했습니다.') ? tr(
: tr( 'msg.userfront.error.detail_generic',
'msg.userfront.error.detail_request', fallback: '오류가 발생했습니다.',
fallback: '요청을 처리하는 중 문제가 발생했습니다.', )
))); : tr(
'msg.userfront.error.detail_request',
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
)));
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF7F8FA), backgroundColor: const Color(0xFFF7F8FA),
@@ -124,20 +130,29 @@ class ErrorScreen extends StatelessWidget {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF111827), backgroundColor: const Color(0xFF111827),
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
), ),
child: Text( child: Text(
tr('ui.userfront.error.go_login', fallback: '로그인으로 이동'), tr(
'ui.userfront.error.go_login',
fallback: '로그인으로 이동',
),
), ),
), ),
OutlinedButton( OutlinedButton(
onPressed: () => context.go('/'), onPressed: () => context.go('/'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF111827), foregroundColor: const Color(0xFF111827),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
side: const BorderSide(color: Color(0xFFCBD5F5)), side: const BorderSide(color: Color(0xFFCBD5F5)),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),

View File

@@ -17,7 +17,9 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv; _drySendEnabled =
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
!AuthProxyService.isProdEnv;
} }
Future<void> _handlePasswordReset() async { Future<void> _handlePasswordReset() async {
@@ -44,7 +46,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
await AuthProxyService.initiatePasswordReset(loginId, drySend: _drySendEnabled); await AuthProxyService.initiatePasswordReset(
loginId,
drySend: _drySendEnabled,
);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -107,16 +112,16 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
children: [ children: [
Text( Text(
tr('ui.userfront.forgot.heading', fallback: '비밀번호를 잊으셨나요?'), tr('ui.userfront.forgot.heading', fallback: '비밀번호를 잊으셨나요?'),
style: TextStyle( style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
fontSize: 28,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
if (_drySendEnabled) ...[ if (_drySendEnabled) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFFF3CD), color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -124,7 +129,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
), ),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)), const Icon(
Icons.warning_amber_rounded,
color: Color(0xFF8A6D3B),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
@@ -132,7 +140,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
'msg.userfront.forgot.dry_send', 'msg.userfront.forgot.dry_send',
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.', fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
), ),
style: const TextStyle(color: Color(0xFF8A6D3B), fontSize: 12), style: const TextStyle(
color: Color(0xFF8A6D3B),
fontSize: 12,
),
), ),
), ),
], ],
@@ -172,13 +183,13 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
? const SizedBox( ? const SizedBox(
height: 20, height: 20,
width: 20, width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
) )
: Text( : Text(
tr( tr('ui.userfront.forgot.submit', fallback: '재설정 링크 전송'),
'ui.userfront.forgot.submit',
fallback: '재설정 링크 전송',
),
), ),
), ),
], ],

View File

@@ -18,7 +18,12 @@ class LoginScreen extends ConsumerStatefulWidget {
final String? loginChallenge; final String? loginChallenge;
final String? redirectUrl; final String? redirectUrl;
const LoginScreen({super.key, this.verificationToken, this.loginChallenge, this.redirectUrl}); const LoginScreen({
super.key,
this.verificationToken,
this.loginChallenge,
this.redirectUrl,
});
@override @override
ConsumerState<LoginScreen> createState() => _LoginScreenState(); ConsumerState<LoginScreen> createState() => _LoginScreenState();
@@ -28,7 +33,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
final TextEditingController _linkIdController = TextEditingController(); final TextEditingController _linkIdController = TextEditingController();
final TextEditingController _passwordLoginIdController = TextEditingController(); final TextEditingController _passwordLoginIdController =
TextEditingController();
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
String? _redirectUrl; String? _redirectUrl;
String? _loginChallenge; String? _loginChallenge;
@@ -41,8 +47,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
int _qrRemainingSeconds = 0; int _qrRemainingSeconds = 0;
Timer? _qrCountdownTimer; Timer? _qrCountdownTimer;
int _qrPollIntervalMs = 2000; int _qrPollIntervalMs = 2000;
final TextEditingController _shortCodePrefixController = TextEditingController(); final TextEditingController _shortCodePrefixController =
final TextEditingController _shortCodeDigitsController = TextEditingController(); TextEditingController();
final TextEditingController _shortCodeDigitsController =
TextEditingController();
String? _linkPendingRef; String? _linkPendingRef;
String? _lastLinkLoginId; String? _lastLinkLoginId;
bool _lastLinkIsEmail = true; bool _lastLinkIsEmail = true;
@@ -75,7 +83,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this, initialIndex: 1); _tabController = TabController(length: 3, vsync: this, initialIndex: 1);
_tabController.addListener(_handleTabSelection); _tabController.addListener(_handleTabSelection);
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv; _drySendEnabled =
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
!AuthProxyService.isProdEnv;
_redirectUrl = widget.redirectUrl; _redirectUrl = widget.redirectUrl;
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
@@ -89,15 +99,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
} }
} }
_loginChallenge = widget.loginChallenge ?? uri.queryParameters['login_challenge']; _loginChallenge =
widget.loginChallenge ?? uri.queryParameters['login_challenge'];
final loginIdParam = uri.queryParameters['loginId']; final loginIdParam = uri.queryParameters['loginId'];
final codeParam = uri.queryParameters['code']; final codeParam = uri.queryParameters['code'];
final pendingRefParam = uri.queryParameters['pendingRef']; final pendingRefParam = uri.queryParameters['pendingRef'];
final hasShortCodePath = uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l'; final hasShortCodePath =
uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
final hasTokenParam = uri.queryParameters.containsKey('t'); final hasTokenParam = uri.queryParameters.containsKey('t');
final hasVerificationToken = widget.verificationToken != null || hasTokenParam; final hasVerificationToken =
widget.verificationToken != null || hasTokenParam;
final hasLoginCode = loginIdParam != null && codeParam != null; final hasLoginCode = loginIdParam != null && codeParam != null;
_verificationOnly = hasVerificationToken || hasLoginCode || hasShortCodePath; _verificationOnly =
hasVerificationToken || hasLoginCode || hasShortCodePath;
final notice = uri.queryParameters['notice']; final notice = uri.queryParameters['notice'];
if (hasShortCodePath) { if (hasShortCodePath) {
@@ -150,9 +164,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
tr( tr(
'msg.userfront.login.cookie_check_failed', 'msg.userfront.login.cookie_check_failed',
fallback: '로그인 확인 실패: {{error}}', fallback: '로그인 확인 실패: {{error}}',
params: { params: {'error': e.toString().replaceFirst('Exception: ', '')},
'error': e.toString().replaceFirst('Exception: ', ''),
},
), ),
); );
} }
@@ -171,8 +183,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final token = AuthTokenStore.getToken(); final token = AuthTokenStore.getToken();
if (token != null && token.isNotEmpty) { if (token != null && token.isNotEmpty) {
if (WebAuthIntegration.isPopup() || (_redirectUrl != null && _redirectUrl!.isNotEmpty)) { if (WebAuthIntegration.isPopup() ||
debugPrint("[Auth] Cookie session with external integration. Notifying..."); (_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
debugPrint(
"[Auth] Cookie session with external integration. Notifying...",
);
WebAuthIntegration.sendLoginSuccess(token); WebAuthIntegration.sendLoginSuccess(token);
return; return;
} }
@@ -200,7 +215,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
try { try {
await AuthProxyService.checkCookieSession(); await AuthProxyService.checkCookieSession();
AuthTokenStore.setCookieMode(provider: AuthTokenStore.getProvider() ?? 'ory'); AuthTokenStore.setCookieMode(
provider: AuthTokenStore.getProvider() ?? 'ory',
);
await _acceptOidcLoginAndRedirect(); await _acceptOidcLoginAndRedirect();
} catch (e) { } catch (e) {
debugPrint("[Auth] OIDC auto-accept cookie check failed: $e"); debugPrint("[Auth] OIDC auto-accept cookie check failed: $e");
@@ -289,7 +306,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final parts = jwt.split('.'); final parts = jwt.split('.');
if (parts.length != 3) return 'User'; if (parts.length != 3) return 'User';
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))); final payload = utf8.decode(
base64Url.decode(base64Url.normalize(parts[1])),
);
final data = json.decode(payload); final data = json.decode(payload);
return data['name'] ?? data['email'] ?? data['sub'] ?? 'User'; return data['name'] ?? data['email'] ?? data['sub'] ?? 'User';
} catch (e) { } catch (e) {
@@ -360,65 +379,65 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _startQrPolling() { void _startQrPolling() {
_qrPollingTimer?.cancel(); _qrPollingTimer?.cancel();
_qrPollingTimer = Timer.periodic(Duration(milliseconds: _qrPollIntervalMs), (timer) async { _qrPollingTimer = Timer.periodic(
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) { Duration(milliseconds: _qrPollIntervalMs),
timer.cancel(); (timer) async {
return; if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
} timer.cancel();
return;
}
try { try {
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!); final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
if (res['error'] == 'slow_down') { if (res['error'] == 'slow_down') {
final interval = res['interval']; final interval = res['interval'];
if (interval is int && interval > 0) { if (interval is int && interval > 0) {
final nextIntervalMs = interval * 1000; final nextIntervalMs = interval * 1000;
if (nextIntervalMs != _qrPollIntervalMs) { if (nextIntervalMs != _qrPollIntervalMs) {
_qrPollIntervalMs = nextIntervalMs; _qrPollIntervalMs = nextIntervalMs;
timer.cancel();
_startQrPolling();
return;
}
} else {
_qrPollIntervalMs += 500;
timer.cancel(); timer.cancel();
_startQrPolling(); _startQrPolling();
return; return;
} }
} else { }
_qrPollIntervalMs += 500; if (res['error'] == 'authorization_pending') {
timer.cancel();
_startQrPolling();
return; return;
} }
} if (res['error'] == 'expired_token') {
if (res['error'] == 'authorization_pending') { timer.cancel();
return; _qrCountdownTimer?.cancel();
}
if (res['error'] == 'expired_token') {
timer.cancel();
_qrCountdownTimer?.cancel();
_showError(
tr(
'msg.userfront.login.qr_expired',
fallback: 'QR 세션이 만료되었습니다.',
),
);
return;
}
if (res['status'] == 'ok') {
timer.cancel();
_qrCountdownTimer?.cancel();
final token = res['sessionJwt'] ?? res['token'];
if (token is String && token.isNotEmpty) {
_completeLoginFromToken(token);
} else {
_showError( _showError(
tr( tr('msg.userfront.login.qr_expired', fallback: 'QR 세션이 만료되었습니다.'),
'msg.userfront.login.token_missing',
fallback: '로그인 토큰을 확인할 수 없습니다.',
),
); );
return;
} }
if (res['status'] == 'ok') {
timer.cancel();
_qrCountdownTimer?.cancel();
final token = res['sessionJwt'] ?? res['token'];
if (token is String && token.isNotEmpty) {
_completeLoginFromToken(token);
} else {
_showError(
tr(
'msg.userfront.login.token_missing',
fallback: '로그인 토큰을 확인할 수 없습니다.',
),
);
}
}
} catch (e) {
debugPrint("[QR] Polling error: $e");
} }
} catch (e) { },
debugPrint("[QR] Polling error: $e"); );
}
});
} }
void _stopQrPolling() { void _stopQrPolling() {
@@ -486,21 +505,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Duration redirectDelay = const Duration(seconds: 2), Duration redirectDelay = const Duration(seconds: 2),
}) { }) {
if (!mounted) return; if (!mounted) return;
final resolvedTitle = title ?? final resolvedTitle =
tr( title ?? tr('ui.userfront.login.verification.title', fallback: '승인 완료');
'ui.userfront.login.verification.title', final resolvedPageTitle =
fallback: '승인 완료', pageTitle ??
); tr('ui.userfront.login.verification.page_title', fallback: '로그인 승인');
final resolvedPageTitle = pageTitle ?? final resolvedActionLabel =
tr( actionLabel ??
'ui.userfront.login.verification.page_title', tr('ui.userfront.login.verification.action_label', fallback: '확인');
fallback: '로그인 승인',
);
final resolvedActionLabel = actionLabel ??
tr(
'ui.userfront.login.verification.action_label',
fallback: '확인',
);
setState(() { setState(() {
_verificationApproved = true; _verificationApproved = true;
_verificationMessage = message; _verificationMessage = message;
@@ -524,11 +536,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.check_circle_outline, color: Colors.green, size: 72), const Icon(
Icons.check_circle_outline,
color: Colors.green,
size: 72,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
_verificationTitle, _verificationTitle,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green), style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.green,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
@@ -544,7 +564,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 24), const SizedBox(height: 24),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
final hasLocalSession = AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie(); final hasLocalSession =
AuthTokenStore.getToken() != null ||
AuthTokenStore.usesCookie();
final target = hasLocalSession ? '/' : '/signin'; final target = hasLocalSession ? '/' : '/signin';
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -586,10 +608,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (status == 'approved' || (jwt == null && _verificationOnly)) { if (status == 'approved' || (jwt == null && _verificationOnly)) {
if (mounted) { if (mounted) {
_markVerificationApproved( _markVerificationApproved(approvedMessage, actionPath: actionPath);
approvedMessage,
actionPath: actionPath,
);
} }
return; return;
} }
@@ -602,18 +621,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
); );
return; return;
} }
_markVerificationApproved( _markVerificationApproved(approvedMessage, actionPath: actionPath);
approvedMessage,
actionPath: actionPath,
);
return; return;
} }
if (mounted) { if (mounted) {
_markVerificationApproved( _markVerificationApproved(approvedMessage, actionPath: actionPath);
approvedMessage,
actionPath: actionPath,
);
} }
} catch (e) { } catch (e) {
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e"); debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
@@ -629,9 +642,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
} }
} }
Future<void> _verifyLoginCode(String loginId, String code, {String? pendingRef}) async { Future<void> _verifyLoginCode(
String loginId,
String code, {
String? pendingRef,
}) async {
final sanitizedLoginId = loginId.replaceAll(' ', '+'); final sanitizedLoginId = loginId.replaceAll(' ', '+');
debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId"); debugPrint(
"[Auth] Starting code verification for loginId: $sanitizedLoginId",
);
final approvedMessage = tr( final approvedMessage = tr(
'msg.userfront.login.verification.approved', 'msg.userfront.login.verification.approved',
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.', fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
@@ -653,16 +672,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
); );
final jwt = res['sessionJwt'] ?? res['token']; final jwt = res['sessionJwt'] ?? res['token'];
final status = res['status']?.toString(); final status = res['status']?.toString();
debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId"); debugPrint(
"[Auth] Code verification successful for loginId: $sanitizedLoginId",
);
final hasLocalSession = await _hasValidLocalSession(); final hasLocalSession = await _hasValidLocalSession();
final actionPath = hasLocalSession ? '/' : '/signin'; final actionPath = hasLocalSession ? '/' : '/signin';
if (jwt == null && status == 'approved') { if (jwt == null && status == 'approved') {
if (mounted) { if (mounted) {
_markVerificationApproved( _markVerificationApproved(approvedMessage, actionPath: actionPath);
approvedMessage,
actionPath: actionPath,
);
} }
return; return;
} }
@@ -676,18 +694,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return; return;
} }
if (_verificationOnly) { if (_verificationOnly) {
_markVerificationApproved( _markVerificationApproved(approvedMessage, actionPath: actionPath);
approvedMessage,
actionPath: actionPath,
);
return; return;
} }
_markVerificationApproved( _markVerificationApproved(
linkLoginMessage, linkLoginMessage,
title: tr( title: tr('ui.userfront.login.link.title', fallback: '링크 로그인 완료'),
'ui.userfront.login.link.title',
fallback: '링크 로그인 완료',
),
pageTitle: tr( pageTitle: tr(
'ui.userfront.login.link.page_title', 'ui.userfront.login.link.page_title',
fallback: '링크 로그인', fallback: '링크 로그인',
@@ -703,13 +715,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
} }
if (_verificationOnly && mounted) { if (_verificationOnly && mounted) {
_markVerificationApproved( _markVerificationApproved(approvedMessage, actionPath: actionPath);
approvedMessage,
actionPath: actionPath,
);
} }
} catch (e) { } catch (e) {
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e"); debugPrint(
"[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e",
);
if (mounted) { if (mounted) {
_showError( _showError(
tr( tr(
@@ -747,10 +758,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt == null && status == 'approved') { if (jwt == null && status == 'approved') {
if (mounted) { if (mounted) {
_markVerificationApproved( _markVerificationApproved(approvedMessage, actionPath: actionPath);
approvedMessage,
actionPath: actionPath,
);
} }
return; return;
} }
@@ -764,10 +772,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return; return;
} }
if (_verificationOnly) { if (_verificationOnly) {
_markVerificationApproved( _markVerificationApproved(approvedMessage, actionPath: actionPath);
approvedMessage,
actionPath: actionPath,
);
return; return;
} }
_completeLoginFromToken(jwt, provider: res['provider'] as String?); _completeLoginFromToken(jwt, provider: res['provider'] as String?);
@@ -775,10 +780,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
} }
if (_verificationOnly && mounted) { if (_verificationOnly && mounted) {
_markVerificationApproved( _markVerificationApproved(approvedMessage, actionPath: actionPath);
approvedMessage,
actionPath: actionPath,
);
} }
} catch (e) { } catch (e) {
debugPrint("[Auth] Short code verification FAILED. Error: $e"); debugPrint("[Auth] Short code verification FAILED. Error: $e");
@@ -836,7 +838,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
); );
try { try {
final res = await AuthProxyService.loginWithPassword(loginId, password, loginChallenge: _loginChallenge); final res = await AuthProxyService.loginWithPassword(
loginId,
password,
loginChallenge: _loginChallenge,
);
final jwt = res['sessionJwt']; final jwt = res['sessionJwt'];
final provider = res['provider'] as String?; final provider = res['provider'] as String?;
final redirectTo = res['redirectTo'] as String?; final redirectTo = res['redirectTo'] as String?;
@@ -860,9 +866,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
tr( tr(
'msg.userfront.login.password.failed', 'msg.userfront.login.password.failed',
fallback: '로그인 실패: {{error}}', fallback: '로그인 실패: {{error}}',
params: { params: {'error': e.toString().replaceFirst('Exception: ', '')},
'error': e.toString().replaceFirst('Exception: ', ''),
},
), ),
); );
} }
@@ -900,13 +904,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
} }
} }
Future<void> _startEnchantedFlow(String loginId, {required bool isEmail, bool codeOnly = false}) async { Future<void> _startEnchantedFlow(
String loginId, {
required bool isEmail,
bool codeOnly = false,
}) async {
try { try {
if (mounted) { if (mounted) {
showDialog( showDialog(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) => const Center(child: CircularProgressIndicator()), builder: (context) =>
const Center(child: CircularProgressIndicator()),
); );
} }
@@ -921,7 +930,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final interval = initResponse['interval']; final interval = initResponse['interval'];
final resendAfter = initResponse['resendAfter']; final resendAfter = initResponse['resendAfter'];
final expiresIn = initResponse['expiresIn']; final expiresIn = initResponse['expiresIn'];
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider"); debugPrint(
"[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider",
);
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -974,7 +985,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
} }
} }
Future<void> _pollForSession(String pendingRef, {Duration? initialInterval}) async { Future<void> _pollForSession(
String pendingRef, {
Duration? initialInterval,
}) async {
int attempts = 0; int attempts = 0;
const maxAttempts = 60; const maxAttempts = 60;
var pollInterval = initialInterval ?? const Duration(seconds: 2); var pollInterval = initialInterval ?? const Duration(seconds: 2);
@@ -1047,10 +1061,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
debugPrint("[Auth] Polling timed out for ref: $pendingRef"); debugPrint("[Auth] Polling timed out for ref: $pendingRef");
Navigator.of(context).pop(); Navigator.of(context).pop();
_showError( _showError(
tr( tr('msg.userfront.login.link_timeout', fallback: '로그인 요청 시간이 초과되었습니다.'),
'msg.userfront.login.link_timeout',
fallback: '로그인 요청 시간이 초과되었습니다.',
),
); );
} }
} }
@@ -1138,8 +1149,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
} }
} }
if (WebAuthIntegration.isPopup() || (_redirectUrl != null && _redirectUrl!.isNotEmpty)) { if (WebAuthIntegration.isPopup() ||
debugPrint("[Auth] External integration detected (popup or redirect). Notifying..."); (_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
debugPrint(
"[Auth] External integration detected (popup or redirect). Notifying...",
);
WebAuthIntegration.sendLoginSuccess(token); WebAuthIntegration.sendLoginSuccess(token);
return; return;
} }
@@ -1224,7 +1238,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (_drySendEnabled) ...[ if (_drySendEnabled) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFFF3CD), color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -1232,13 +1249,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
), ),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)), const Icon(
Icons.warning_amber_rounded,
color: Color(0xFF8A6D3B),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
tr( tr(
'msg.userfront.login.dry_send', 'msg.userfront.login.dry_send',
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.', fallback:
'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
), ),
style: const TextStyle( style: const TextStyle(
color: Color(0xFF8A6D3B), color: Color(0xFF8A6D3B),
@@ -1294,7 +1315,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
fallback: '이메일 또는 휴대폰 번호', fallback: '이메일 또는 휴대폰 번호',
), ),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline), prefixIcon: const Icon(
Icons.person_outline,
),
), ),
onSubmitted: (_) => _handlePasswordLogin(), onSubmitted: (_) => _handlePasswordLogin(),
), ),
@@ -1308,7 +1331,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
fallback: '비밀번호', fallback: '비밀번호',
), ),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline), prefixIcon: const Icon(
Icons.lock_outline,
),
), ),
onSubmitted: (_) => _handlePasswordLogin(), onSubmitted: (_) => _handlePasswordLogin(),
), ),
@@ -1319,7 +1344,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
minimumSize: const Size.fromHeight(50), minimumSize: const Size.fromHeight(50),
), ),
child: Text( child: Text(
tr('ui.userfront.login.action.submit', fallback: '로그인'), tr(
'ui.userfront.login.action.submit',
fallback: '로그인',
),
), ),
), ),
], ],
@@ -1340,7 +1368,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
), ),
hintText: '', hintText: '',
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline), prefixIcon: const Icon(
Icons.person_outline,
),
), ),
onSubmitted: (_) => _handleLinkLogin(), onSubmitted: (_) => _handleLinkLogin(),
), ),
@@ -1363,7 +1393,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'msg.userfront.login.link.helper', 'msg.userfront.login.link.helper',
fallback: '입력하신 정보로 로그인 링크를 전송합니다.', fallback: '입력하신 정보로 로그인 링크를 전송합니다.',
), ),
style: const TextStyle(color: Colors.grey, fontSize: 12), style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
@@ -1371,9 +1404,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Text( Text(
tr( tr(
'msg.userfront.login.link.short_code_help', 'msg.userfront.login.link.short_code_help',
fallback: '링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.', fallback:
'링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.',
),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
), ),
style: const TextStyle(color: Colors.grey, fontSize: 12),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -1382,16 +1419,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Expanded( Expanded(
flex: 2, flex: 2,
child: TextField( child: TextField(
controller: _shortCodePrefixController, controller:
textCapitalization: TextCapitalization.characters, _shortCodePrefixController,
textCapitalization:
TextCapitalization.characters,
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr(
'ui.userfront.login.short_code.prefix', 'ui.userfront.login.short_code.prefix',
fallback: '영문 2자리', fallback: '영문 2자리',
), ),
border: const OutlineInputBorder(), border:
const OutlineInputBorder(),
hintText: 'AB', hintText: 'AB',
hintStyle: const TextStyle(color: Colors.grey), hintStyle: const TextStyle(
color: Colors.grey,
),
), ),
maxLength: 2, maxLength: 2,
), ),
@@ -1400,22 +1442,28 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Expanded( Expanded(
flex: 4, flex: 4,
child: TextField( child: TextField(
controller: _shortCodeDigitsController, controller:
_shortCodeDigitsController,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr(
'ui.userfront.login.short_code.digits', 'ui.userfront.login.short_code.digits',
fallback: '숫자 6자리', fallback: '숫자 6자리',
), ),
border: const OutlineInputBorder(), border:
const OutlineInputBorder(),
hintText: '345678', hintText: '345678',
hintStyle: const TextStyle(color: Colors.grey), hintStyle: const TextStyle(
color: Colors.grey,
),
suffixText: _linkExpireSeconds > 0 suffixText: _linkExpireSeconds > 0
? tr( ? tr(
'ui.userfront.login.short_code.expire_time', 'ui.userfront.login.short_code.expire_time',
fallback: '유효시간 {{time}}', fallback: '유효시간 {{time}}',
params: { params: {
'time': _formatTime(_linkExpireSeconds), 'time': _formatTime(
_linkExpireSeconds,
),
}, },
) )
: null, : null,
@@ -1428,13 +1476,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
final prefix = _shortCodePrefixController.text.trim().toUpperCase(); final prefix =
final digits = _shortCodeDigitsController.text.trim(); _shortCodePrefixController.text
if (prefix.length != 2 || digits.length != 6) { .trim()
.toUpperCase();
final digits =
_shortCodeDigitsController.text
.trim();
if (prefix.length != 2 ||
digits.length != 6) {
_showError( _showError(
tr( tr(
'msg.userfront.login.short_code.invalid', 'msg.userfront.login.short_code.invalid',
fallback: '문자 2개와 숫자 6자리를 입력해 주세요.', fallback:
'문자 2개와 숫자 6자리를 입력해 주세요.',
), ),
); );
return; return;
@@ -1458,27 +1513,35 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_showInfo( _showInfo(
tr( tr(
'msg.userfront.login.link.resend_wait', 'msg.userfront.login.link.resend_wait',
fallback: '재발송은 {{time}} 후 가능합니다.', fallback:
'재발송은 {{time}} 후 가능합니다.',
params: { params: {
'time': _formatTime(_linkResendSeconds), 'time': _formatTime(
_linkResendSeconds,
),
}, },
), ),
); );
return; return;
} }
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim(); final loginId =
_lastLinkLoginId ??
_linkIdController.text.trim();
if (loginId.isEmpty) { if (loginId.isEmpty) {
_showError( _showError(
tr( tr(
'msg.userfront.login.link.missing_login_id', 'msg.userfront.login.link.missing_login_id',
fallback: '이메일 또는 휴대폰 번호를 입력해 주세요.', fallback:
'이메일 또는 휴대폰 번호를 입력해 주세요.',
), ),
); );
return; return;
} }
_startEnchantedFlow( _startEnchantedFlow(
loginId, loginId,
isEmail: _lastLinkIsEmail || loginId.contains('@'), isEmail:
_lastLinkIsEmail ||
loginId.contains('@'),
codeOnly: false, codeOnly: false,
); );
}, },
@@ -1488,7 +1551,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'ui.userfront.login.link.resend_with_time', 'ui.userfront.login.link.resend_with_time',
fallback: '재발송 ({{time}})', fallback: '재발송 ({{time}})',
params: { params: {
'time': _formatTime(_linkResendSeconds), 'time': _formatTime(
_linkResendSeconds,
),
}, },
) )
: tr( : tr(
@@ -1505,15 +1570,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_showInfo( _showInfo(
tr( tr(
'msg.userfront.login.link.resend_wait', 'msg.userfront.login.link.resend_wait',
fallback: '재발송은 {{time}} 후 가능합니다.', fallback:
'재발송은 {{time}} 후 가능합니다.',
params: { params: {
'time': _formatTime(_linkResendSeconds), 'time': _formatTime(
_linkResendSeconds,
),
}, },
), ),
); );
return; return;
} }
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim(); final loginId =
_lastLinkLoginId ??
_linkIdController.text.trim();
if (loginId.isEmpty) { if (loginId.isEmpty) {
_showError( _showError(
tr( tr(
@@ -1534,7 +1604,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'ui.userfront.login.link.code_only', 'ui.userfront.login.link.code_only',
fallback: '코드만 받기({{time}})', fallback: '코드만 받기({{time}})',
params: { params: {
'time': _formatTime(_linkResendSeconds), 'time': _formatTime(
_linkResendSeconds,
),
}, },
), ),
), ),
@@ -1553,13 +1625,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const CircularProgressIndicator() const CircularProgressIndicator()
else if (_qrImageBase64 != null) else if (_qrImageBase64 != null)
Column( Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment:
CrossAxisAlignment.center,
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300), border: Border.all(
borderRadius: BorderRadius.circular(12), color: Colors.grey.shade300,
),
borderRadius: BorderRadius.circular(
12,
),
), ),
child: QrImageView( child: QrImageView(
data: _qrImageBase64!, data: _qrImageBase64!,
@@ -1570,20 +1647,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
_qrRemainingSeconds > 0 _qrRemainingSeconds > 0
? tr( ? tr(
'ui.userfront.login.qr.remaining', 'ui.userfront.login.qr.remaining',
fallback: '남은 시간: {{time}}', fallback: '남은 시간: {{time}}',
params: { params: {
'time': _formatTime(_qrRemainingSeconds), 'time': _formatTime(
}, _qrRemainingSeconds,
) ),
: tr( },
'ui.userfront.login.qr.expired', )
fallback: 'QR 코드 만료됨', : tr(
), 'ui.userfront.login.qr.expired',
fallback: 'QR 코드 만료됨',
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red, color: _qrRemainingSeconds > 30
? Colors.blue
: Colors.red,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -1594,7 +1675,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
fallback: '모바일 앱으로 스캔하세요', fallback: '모바일 앱으로 스캔하세요',
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 12), style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
), ),
TextButton( TextButton(
onPressed: _startQrFlow, onPressed: _startQrFlow,
@@ -1640,7 +1724,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'msg.userfront.login.no_account', 'msg.userfront.login.no_account',
fallback: '계정이 없으신가요?', fallback: '계정이 없으신가요?',
), ),
style: const TextStyle(color: Colors.grey, fontSize: 14), style: const TextStyle(
color: Colors.grey,
fontSize: 14,
),
), ),
TextButton( TextButton(
onPressed: () => context.push('/signup'), onPressed: () => context.push('/signup'),

View File

@@ -14,18 +14,22 @@ class LoginSuccessScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.check_circle_outline, size: 80, color: Colors.green), const Icon(
Icons.check_circle_outline,
size: 80,
color: Colors.green,
),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
tr('ui.userfront.login_success.title', fallback: '로그인 완료'), tr('ui.userfront.login_success.title', fallback: '로그인 완료'),
style: TextStyle( style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
fontSize: 32,
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
tr('msg.userfront.login_success.subtitle', fallback: '성공적으로 로그인되었습니다.'), tr(
'msg.userfront.login_success.subtitle',
fallback: '성공적으로 로그인되었습니다.',
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 16), style: const TextStyle(color: Colors.grey, fontSize: 16),
), ),
@@ -38,12 +42,18 @@ class LoginSuccessScreen extends StatelessWidget {
}, },
icon: const Icon(Icons.camera_alt, size: 28), icon: const Icon(Icons.camera_alt, size: 28),
label: Text( label: Text(
tr('ui.userfront.login_success.qr', fallback: 'QR 인증 (카메라 켜기)'), tr(
'ui.userfront.login_success.qr',
fallback: 'QR 인증 (카메라 켜기)',
),
), ),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게 minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
backgroundColor: Colors.blue.shade700, backgroundColor: Colors.blue.shade700,
textStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), textStyle: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),

View File

@@ -226,7 +226,11 @@ class _QRScanScreenState extends State<QRScanScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
title, title,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color), style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: color,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
@@ -268,7 +272,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
controller: controller, controller: controller,
onDetect: _onDetect, onDetect: _onDetect,
errorBuilder: (context, error) { errorBuilder: (context, error) {
final isPermissionDenied = error.errorCode == final isPermissionDenied =
error.errorCode ==
MobileScannerErrorCode.permissionDenied; MobileScannerErrorCode.permissionDenied;
return Center( return Center(
child: Column( child: Column(
@@ -295,7 +300,10 @@ class _QRScanScreenState extends State<QRScanScreen> {
: _requestCameraPermission, : _requestCameraPermission,
child: Text( child: Text(
_isRequestingCamera _isRequestingCamera
? tr('ui.common.requesting', fallback: '요청 중...') ? tr(
'ui.common.requesting',
fallback: '요청 중...',
)
: tr( : tr(
'ui.userfront.qr.request_permission', 'ui.userfront.qr.request_permission',
fallback: '카메라 권한 요청하기', fallback: '카메라 권한 요청하기',

View File

@@ -13,7 +13,8 @@ class ResetPasswordScreen extends StatefulWidget {
class _ResetPasswordScreenState extends State<ResetPasswordScreen> { class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
final TextEditingController _confirmPasswordController = TextEditingController(); final TextEditingController _confirmPasswordController =
TextEditingController();
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
bool _isLoading = false; bool _isLoading = false;
String? _loginId; String? _loginId;
@@ -31,13 +32,13 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
// 2. Fallback to URI query parameter if not available via router // 2. Fallback to URI query parameter if not available via router
if (_loginId == null || _loginId!.isEmpty) { if (_loginId == null || _loginId!.isEmpty) {
final uri = Uri.base; final uri = Uri.base;
_loginId = uri.queryParameters['loginId']; _loginId = uri.queryParameters['loginId'];
} }
// 토큰도 함께 읽어놓는다. // 토큰도 함께 읽어놓는다.
final uri = Uri.base; final uri = Uri.base;
_token = uri.queryParameters['token']; _token = uri.queryParameters['token'];
_loadPolicy(); _loadPolicy();
} }
@@ -66,7 +67,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Future<void> _handlePasswordReset() async { Future<void> _handlePasswordReset() async {
if (_formKey.currentState?.validate() != true) return; if (_formKey.currentState?.validate() != true) return;
if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) { if ((_loginId == null || _loginId!.isEmpty) &&
(_token == null || _token!.isEmpty)) {
_showError( _showError(
tr( tr(
'msg.userfront.reset.invalid_link', 'msg.userfront.reset.invalid_link',
@@ -163,9 +165,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
); );
} }
if (requiresNumber) { if (requiresNumber) {
parts.add( parts.add(tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'));
tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'),
);
} }
if (requiresSymbol) { if (requiresSymbol) {
parts.add( parts.add(
@@ -180,16 +180,16 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정')),
tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정'),
),
centerTitle: true, centerTitle: true,
), ),
body: Center( body: Center(
child: Container( child: Container(
constraints: const BoxConstraints(maxWidth: 400), constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: (_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty) child:
(_loginId == null || _loginId!.isEmpty) &&
(_token == null || _token!.isEmpty)
? _buildInvalidTokenView() ? _buildInvalidTokenView()
: Form( : Form(
key: _formKey, key: _formKey,
@@ -227,7 +227,9 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
prefixIcon: const Icon(Icons.lock_outline), prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
_isPasswordObscured ? Icons.visibility_off : Icons.visibility, _isPasswordObscured
? Icons.visibility_off
: Icons.visibility,
), ),
onPressed: () { onPressed: () {
setState(() { setState(() {
@@ -244,7 +246,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
fallback: '비밀번호를 입력해주세요.', fallback: '비밀번호를 입력해주세요.',
); );
} }
final minLength = (_policy?['minLength'] as int?) ?? 12; final minLength =
(_policy?['minLength'] as int?) ?? 12;
if (val.length < minLength) { if (val.length < minLength) {
return tr( return tr(
'msg.userfront.reset.error.min_length', 'msg.userfront.reset.error.min_length',
@@ -262,7 +265,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (hasNumber) typeCount++; if (hasNumber) typeCount++;
if (hasSymbol) typeCount++; if (hasSymbol) typeCount++;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; final minTypes =
(_policy?['minCharacterTypes'] as int?) ?? 0;
if (minTypes > 0 && typeCount < minTypes) { if (minTypes > 0 && typeCount < minTypes) {
return tr( return tr(
'msg.userfront.reset.error.min_types', 'msg.userfront.reset.error.min_types',
@@ -290,7 +294,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
fallback: '최소 1개 이상의 숫자를 포함해야 합니다.', fallback: '최소 1개 이상의 숫자를 포함해야 합니다.',
); );
} }
if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) { if ((_policy?['nonAlphanumeric'] ?? true) &&
!hasSymbol) {
return tr( return tr(
'msg.userfront.reset.error.symbol', 'msg.userfront.reset.error.symbol',
fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.', fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.',
@@ -312,11 +317,14 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
prefixIcon: const Icon(Icons.lock_outline), prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
_isConfirmPasswordObscured ? Icons.visibility_off : Icons.visibility, _isConfirmPasswordObscured
? Icons.visibility_off
: Icons.visibility,
), ),
onPressed: () { onPressed: () {
setState(() { setState(() {
_isConfirmPasswordObscured = !_isConfirmPasswordObscured; _isConfirmPasswordObscured =
!_isConfirmPasswordObscured;
}); });
}, },
), ),
@@ -369,8 +377,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
const Icon(Icons.error_outline, color: Colors.red, size: 60), const Icon(Icons.error_outline, color: Colors.red, size: 60),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
tr('msg.userfront.reset.invalid_title', tr('msg.userfront.reset.invalid_title', fallback: '유효하지 않은 링크입니다.'),
fallback: '유효하지 않은 링크입니다.'),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),

View File

@@ -164,30 +164,39 @@ class _SignupScreenState extends State<SignupScreen> {
final email = _emailController.text.trim(); final email = _emailController.text.trim();
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(email)) { if (!emailRegex.hasMatch(email)) {
setState(() => _emailError = tr( setState(
'msg.userfront.signup.email.invalid', () => _emailError = tr(
fallback: '유효한 이메일 형식이 아닙니다.', 'msg.userfront.signup.email.invalid',
)); fallback: '유효한 이메일 형식이 아닙니다.',
),
);
return; return;
} }
setState(() { _isLoading = true; _emailError = null; }); setState(() {
_isLoading = true;
_emailError = null;
});
try { try {
final available = await AuthProxyService.checkEmailAvailability(email); final available = await AuthProxyService.checkEmailAvailability(email);
if (!available) { if (!available) {
setState(() => _emailError = tr( setState(
'msg.userfront.signup.email.duplicate', () => _emailError = tr(
fallback: '이미 가입된 이메일입니다.', 'msg.userfront.signup.email.duplicate',
)); fallback: '이미 가입된 이메일입니다.',
),
);
return; return;
} }
await AuthProxyService.sendSignupCode(email, 'email'); await AuthProxyService.sendSignupCode(email, 'email');
_startTimer('email'); _startTimer('email');
} catch (e) { } catch (e) {
setState(() => _emailError = tr( setState(
'msg.userfront.signup.email.send_failed', () => _emailError = tr(
fallback: '발송 실패: {{error}}', 'msg.userfront.signup.email.send_failed',
params: {'error': e.toString()}, fallback: '발송 실패: {{error}}',
)); params: {'error': e.toString()},
),
);
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
@@ -197,7 +206,11 @@ class _SignupScreenState extends State<SignupScreen> {
final code = _emailCodeController.text.trim(); final code = _emailCodeController.text.trim();
if (code.length != 6) return; if (code.length != 6) return;
try { try {
final success = await AuthProxyService.verifySignupCode(_emailController.text.trim(), 'email', code); final success = await AuthProxyService.verifySignupCode(
_emailController.text.trim(),
'email',
code,
);
if (success) { if (success) {
setState(() { setState(() {
_isEmailVerified = true; _isEmailVerified = true;
@@ -206,33 +219,42 @@ class _SignupScreenState extends State<SignupScreen> {
_emailError = null; _emailError = null;
}); });
} else { } else {
setState(() => _emailError = tr( setState(
'msg.userfront.signup.email.code_mismatch', () => _emailError = tr(
fallback: '인증코드가 일치하지 않습니다.', 'msg.userfront.signup.email.code_mismatch',
)); fallback: '인증코드가 일치하지 않습니다.',
),
);
} }
} catch (e) { } catch (e) {
setState(() => _emailError = tr( setState(
'msg.userfront.signup.email.verify_failed', () => _emailError = tr(
fallback: '인증 실패: {{error}}', 'msg.userfront.signup.email.verify_failed',
params: {'error': e.toString()}, fallback: '인증 실패: {{error}}',
)); params: {'error': e.toString()},
),
);
} }
} }
Future<void> _sendPhoneCode() async { Future<void> _sendPhoneCode() async {
final phone = _phoneController.text.trim(); final phone = _phoneController.text.trim();
if (phone.isEmpty) return; if (phone.isEmpty) return;
setState(() { _isLoading = true; _phoneError = null; }); setState(() {
_isLoading = true;
_phoneError = null;
});
try { try {
await AuthProxyService.sendSignupCode(phone, 'phone'); await AuthProxyService.sendSignupCode(phone, 'phone');
_startTimer('phone'); _startTimer('phone');
} catch (e) { } catch (e) {
setState(() => _phoneError = tr( setState(
'msg.userfront.signup.phone.send_failed', () => _phoneError = tr(
fallback: '발송 실패: {{error}}', 'msg.userfront.signup.phone.send_failed',
params: {'error': e.toString()}, fallback: '발송 실패: {{error}}',
)); params: {'error': e.toString()},
),
);
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
@@ -242,7 +264,11 @@ class _SignupScreenState extends State<SignupScreen> {
final code = _phoneCodeController.text.trim(); final code = _phoneCodeController.text.trim();
if (code.length != 6) return; if (code.length != 6) return;
try { try {
final success = await AuthProxyService.verifySignupCode(_phoneController.text.trim(), 'phone', code); final success = await AuthProxyService.verifySignupCode(
_phoneController.text.trim(),
'phone',
code,
);
if (success) { if (success) {
setState(() { setState(() {
_isPhoneVerified = true; _isPhoneVerified = true;
@@ -251,26 +277,32 @@ class _SignupScreenState extends State<SignupScreen> {
_phoneError = null; _phoneError = null;
}); });
} else { } else {
setState(() => _phoneError = tr( setState(
'msg.userfront.signup.phone.code_mismatch', () => _phoneError = tr(
fallback: '인증코드가 일치하지 않습니다.', 'msg.userfront.signup.phone.code_mismatch',
)); fallback: '인증코드가 일치하지 않습니다.',
),
);
} }
} catch (e) { } catch (e) {
setState(() => _phoneError = tr( setState(
'msg.userfront.signup.phone.verify_failed', () => _phoneError = tr(
fallback: '인증 실패: {{error}}', 'msg.userfront.signup.phone.verify_failed',
params: {'error': e.toString()}, fallback: '인증 실패: {{error}}',
)); params: {'error': e.toString()},
),
);
} }
} }
Future<void> _handleSignup() async { Future<void> _handleSignup() async {
if (_passwordController.text != _confirmPasswordController.text) { if (_passwordController.text != _confirmPasswordController.text) {
setState(() => _confirmPasswordError = tr( setState(
'msg.userfront.signup.password.mismatch', () => _confirmPasswordError = tr(
fallback: '비밀번호가 일치하지 않습니다.', 'msg.userfront.signup.password.mismatch',
)); fallback: '비밀번호가 일치하지 않습니다.',
),
);
return; return;
} }
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
@@ -288,7 +320,9 @@ class _SignupScreenState extends State<SignupScreen> {
phone: _phoneController.text.trim(), phone: _phoneController.text.trim(),
affiliationType: _affiliationType, affiliationType: _affiliationType,
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null, companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
department: _deptController.text.trim().isEmpty ? (_affiliationType == 'GENERAL' ? 'External' : '') : _deptController.text.trim(), department: _deptController.text.trim().isEmpty
? (_affiliationType == 'GENERAL' ? 'External' : '')
: _deptController.text.trim(),
termsAccepted: true, termsAccepted: true,
); );
if (mounted) _showSuccessDialog(); if (mounted) _showSuccessDialog();
@@ -394,11 +428,28 @@ class _SignupScreenState extends State<SignupScreen> {
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 12, radius: 12,
backgroundColor: isDone ? Colors.green : (isCurrent ? Colors.black : Colors.grey[300]), backgroundColor: isDone
child: isDone ? const Icon(Icons.check, size: 14, color: Colors.white) : Text('$step', style: TextStyle(color: isCurrent ? Colors.white : Colors.black54, fontSize: 10)), ? Colors.green
: (isCurrent ? Colors.black : Colors.grey[300]),
child: isDone
? const Icon(Icons.check, size: 14, color: Colors.white)
: Text(
'$step',
style: TextStyle(
color: isCurrent ? Colors.white : Colors.black54,
fontSize: 10,
),
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(label, style: TextStyle(fontSize: 9, color: isCurrent ? Colors.black : Colors.grey, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal)), Text(
label,
style: TextStyle(
fontSize: 9,
color: isCurrent ? Colors.black : Colors.grey,
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
),
),
], ],
); );
} }
@@ -438,10 +489,7 @@ class _SignupScreenState extends State<SignupScreen> {
), ),
child: CheckboxListTile( child: CheckboxListTile(
title: Text( title: Text(
tr( tr('ui.userfront.signup.agreement.all', fallback: '모두 동의합니다'),
'ui.userfront.signup.agreement.all',
fallback: '모두 동의합니다',
),
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
), ),
value: _termsAccepted && _privacyAccepted, value: _termsAccepted && _privacyAccepted,
@@ -488,8 +536,10 @@ class _SignupScreenState extends State<SignupScreen> {
return Column( return Column(
children: [ children: [
CheckboxListTile( CheckboxListTile(
title: Text(title, title: Text(
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
value: value, value: value,
onChanged: onChanged, onChanged: onChanged,
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
@@ -508,7 +558,11 @@ class _SignupScreenState extends State<SignupScreen> {
child: SingleChildScrollView( child: SingleChildScrollView(
child: Text( child: Text(
content, content,
style: const TextStyle(fontSize: 12, color: Colors.grey, height: 1.5), style: const TextStyle(
fontSize: 12,
color: Colors.grey,
height: 1.5,
),
), ),
), ),
), ),
@@ -517,8 +571,8 @@ class _SignupScreenState extends State<SignupScreen> {
} }
static String get _tosText => tr( static String get _tosText => tr(
'msg.userfront.signup.tos_full', 'msg.userfront.signup.tos_full',
fallback: """ fallback: """
바론 소프트웨어 이용약관 바론 소프트웨어 이용약관
제1장 총칙 제1장 총칙
@@ -589,11 +643,11 @@ class _SignupScreenState extends State<SignupScreen> {
부칙 부칙
본 약관은 2024년 10월 1일부터 시행됩니다. 본 약관은 2024년 10월 1일부터 시행됩니다.
""", """,
); );
static String get _privacyText => tr( static String get _privacyText => tr(
'msg.userfront.signup.privacy_full', 'msg.userfront.signup.privacy_full',
fallback: """ fallback: """
개인정보 수집 및 이용 동의 개인정보 수집 및 이용 동의
바론서비스 개인정보처리방침 바론서비스 개인정보처리방침
@@ -702,7 +756,7 @@ class _SignupScreenState extends State<SignupScreen> {
제8조 (기타) 제8조 (기타)
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다. 본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
""", """,
); );
Widget _buildStepAuth() { Widget _buildStepAuth() {
return Column( return Column(
@@ -719,7 +773,10 @@ class _SignupScreenState extends State<SignupScreen> {
// 가족사 이메일 안내 문구 // 가족사 이메일 안내 문구
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(6)), decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(6),
),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.info_outline, size: 16, color: Colors.blue), const Icon(Icons.info_outline, size: 16, color: Colors.blue),
@@ -730,7 +787,11 @@ class _SignupScreenState extends State<SignupScreen> {
'msg.userfront.signup.auth.affiliate_notice', 'msg.userfront.signup.auth.affiliate_notice',
fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.', fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
), ),
style: const TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500), style: const TextStyle(
fontSize: 12,
color: Colors.blue,
fontWeight: FontWeight.w500,
),
), ),
), ),
], ],
@@ -764,8 +825,14 @@ class _SignupScreenState extends State<SignupScreen> {
SizedBox( SizedBox(
height: 55, height: 55,
child: ElevatedButton( child: ElevatedButton(
onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode, onPressed: (_isEmailVerified || _isLoading)
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0), ? null
: _sendEmailCode,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[100],
foregroundColor: Colors.black,
elevation: 0,
),
child: Text( child: Text(
_emailSeconds > 0 _emailSeconds > 0
? tr('ui.common.resend', fallback: '재발송') ? tr('ui.common.resend', fallback: '재발송')
@@ -791,8 +858,13 @@ class _SignupScreenState extends State<SignupScreen> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)], inputFormatters: [
onChanged: (val) { if(val.length == 6) _verifyEmailCode(); }, FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
onChanged: (val) {
if (val.length == 6) _verifyEmailCode();
},
), ),
], ],
if (_isEmailVerified) if (_isEmailVerified)
@@ -837,8 +909,14 @@ class _SignupScreenState extends State<SignupScreen> {
SizedBox( SizedBox(
height: 55, height: 55,
child: ElevatedButton( child: ElevatedButton(
onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode, onPressed: (_isPhoneVerified || _isLoading)
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0), ? null
: _sendPhoneCode,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[100],
foregroundColor: Colors.black,
elevation: 0,
),
child: Text( child: Text(
_phoneSeconds > 0 _phoneSeconds > 0
? tr('ui.common.resend', fallback: '재발송') ? tr('ui.common.resend', fallback: '재발송')
@@ -864,8 +942,13 @@ class _SignupScreenState extends State<SignupScreen> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)], inputFormatters: [
onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); }, FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
onChanged: (val) {
if (val.length == 6) _verifyPhoneCode();
},
), ),
], ],
if (_isPhoneVerified) if (_isPhoneVerified)
@@ -903,10 +986,7 @@ class _SignupScreenState extends State<SignupScreen> {
controller: _nameController, controller: _nameController,
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.signup.profile.name', fallback: '이름'),
'ui.userfront.signup.profile.name',
fallback: '이름',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
), ),
@@ -936,19 +1016,13 @@ class _SignupScreenState extends State<SignupScreen> {
DropdownMenuItem( DropdownMenuItem(
value: 'GENERAL', value: 'GENERAL',
child: Text( child: Text(
tr( tr('domain.affiliation.general', fallback: '일반 사용자'),
'domain.affiliation.general',
fallback: '일반 사용자',
),
), ),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'AFFILIATE', value: 'AFFILIATE',
child: Text( child: Text(
tr( tr('domain.affiliation.affiliate', fallback: '가족사 임직원'),
'domain.affiliation.affiliate',
fallback: '가족사 임직원',
),
), ),
), ),
], ],
@@ -985,39 +1059,27 @@ class _SignupScreenState extends State<SignupScreen> {
items: [ items: [
DropdownMenuItem( DropdownMenuItem(
value: 'HANMAC', value: 'HANMAC',
child: Text( child: Text(tr('domain.company.hanmac', fallback: '한맥')),
tr('domain.company.hanmac', fallback: '한맥'),
),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'SAMAN', value: 'SAMAN',
child: Text( child: Text(tr('domain.company.saman', fallback: '삼안')),
tr('domain.company.saman', fallback: '삼안'),
),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'PTC', value: 'PTC',
child: Text( child: Text(tr('domain.company.ptc', fallback: 'PTC')),
tr('domain.company.ptc', fallback: 'PTC'),
),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'JANGHEON', value: 'JANGHEON',
child: Text( child: Text(tr('domain.company.jangheon', fallback: '장헌')),
tr('domain.company.jangheon', fallback: '장헌'),
),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'BARON', value: 'BARON',
child: Text( child: Text(tr('domain.company.baron', fallback: '바론')),
tr('domain.company.baron', fallback: '바론'),
),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'HALLA', value: 'HALLA',
child: Text( child: Text(tr('domain.company.halla', fallback: '한라')),
tr('domain.company.halla', fallback: '한라'),
),
), ),
], ],
onChanged: _isAffiliateEmail onChanged: _isAffiliateEmail
@@ -1038,7 +1100,7 @@ class _SignupScreenState extends State<SignupScreen> {
'ui.userfront.signup.profile.department_optional', 'ui.userfront.signup.profile.department_optional',
fallback: '소속 정보 (선택)', fallback: '소속 정보 (선택)',
), ),
border: const OutlineInputBorder() border: const OutlineInputBorder(),
), ),
), ),
], ],
@@ -1076,36 +1138,16 @@ class _SignupScreenState extends State<SignupScreen> {
); );
} }
if (requiresUpper) { if (requiresUpper) {
parts.add( parts.add(tr('msg.userfront.signup.policy.uppercase', fallback: '대문자'));
tr(
'msg.userfront.signup.policy.uppercase',
fallback: '대문자',
),
);
} }
if (requiresLower) { if (requiresLower) {
parts.add( parts.add(tr('msg.userfront.signup.policy.lowercase', fallback: '소문자'));
tr(
'msg.userfront.signup.policy.lowercase',
fallback: '소문자',
),
);
} }
if (requiresNumber) { if (requiresNumber) {
parts.add( parts.add(tr('msg.userfront.signup.policy.number', fallback: '숫자'));
tr(
'msg.userfront.signup.policy.number',
fallback: '숫자',
),
);
} }
if (requiresSymbol) { if (requiresSymbol) {
parts.add( parts.add(tr('msg.userfront.signup.policy.symbol', fallback: '특수문자'));
tr(
'msg.userfront.signup.policy.symbol',
fallback: '특수문자',
),
);
} }
return tr( return tr(
@@ -1152,7 +1194,10 @@ class _SignupScreenState extends State<SignupScreen> {
// 비밀번호 정책 안내 박스 // 비밀번호 정책 안내 박스
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(8)), decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(8),
),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.security, size: 18, color: Colors.blue), const Icon(Icons.security, size: 18, color: Colors.blue),
@@ -1160,7 +1205,11 @@ class _SignupScreenState extends State<SignupScreen> {
Expanded( Expanded(
child: Text( child: Text(
_buildPolicyDescription(), _buildPolicyDescription(),
style: TextStyle(fontSize: 12, color: Colors.blue[800], fontWeight: FontWeight.w500), style: TextStyle(
fontSize: 12,
color: Colors.blue[800],
fontWeight: FontWeight.w500,
),
), ),
), ),
], ],
@@ -1219,10 +1268,7 @@ class _SignupScreenState extends State<SignupScreen> {
), ),
if (requiresNumber) if (requiresNumber)
_cryptoCheck( _cryptoCheck(
tr( tr('msg.userfront.signup.password.rule.number', fallback: '숫자'),
'msg.userfront.signup.password.rule.number',
fallback: '숫자',
),
hasDigit, hasDigit,
), ),
if (requiresSymbol) if (requiresSymbol)
@@ -1266,9 +1312,19 @@ class _SignupScreenState extends State<SignupScreen> {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(isValid ? Icons.check_circle : Icons.circle_outlined, size: 14, color: isValid ? Colors.green : Colors.grey), Icon(
isValid ? Icons.check_circle : Icons.circle_outlined,
size: 14,
color: isValid ? Colors.green : Colors.grey,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(label, style: TextStyle(fontSize: 11, color: isValid ? Colors.green : Colors.grey)), Text(
label,
style: TextStyle(
fontSize: 11,
color: isValid ? Colors.green : Colors.grey,
),
),
], ],
); );
} }
@@ -1276,8 +1332,10 @@ class _SignupScreenState extends State<SignupScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
bool canGoNext = false; bool canGoNext = false;
if (_currentStep == 1 && _termsAccepted && _privacyAccepted) canGoNext = true; if (_currentStep == 1 && _termsAccepted && _privacyAccepted)
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified) canGoNext = true; canGoNext = true;
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified)
canGoNext = true;
if (_currentStep == 3) { if (_currentStep == 3) {
final nameOk = _nameController.text.trim().isNotEmpty; final nameOk = _nameController.text.trim().isNotEmpty;
if (_affiliationType == 'GENERAL') { if (_affiliationType == 'GENERAL') {
@@ -1314,10 +1372,12 @@ class _SignupScreenState extends State<SignupScreen> {
child: Form( child: Form(
key: _formKey, key: _formKey,
child: _currentStep == 1 child: _currentStep == 1
? _buildStepAgreement() ? _buildStepAgreement()
: (_currentStep == 2 : (_currentStep == 2
? _buildStepAuth() ? _buildStepAuth()
: (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())), : (_currentStep == 3
? _buildStepInfo()
: _buildStepPassword())),
), ),
), ),
), ),
@@ -1329,7 +1389,10 @@ class _SignupScreenState extends State<SignupScreen> {
Expanded( Expanded(
child: OutlinedButton( child: OutlinedButton(
onPressed: () => setState(() => _currentStep--), onPressed: () => setState(() => _currentStep--),
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55), side: const BorderSide(color: Colors.black)), style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(55),
side: const BorderSide(color: Colors.black),
),
child: Text( child: Text(
tr('ui.common.prev', fallback: '이전'), tr('ui.common.prev', fallback: '이전'),
style: const TextStyle(color: Colors.black), style: const TextStyle(color: Colors.black),
@@ -1341,19 +1404,34 @@ class _SignupScreenState extends State<SignupScreen> {
Expanded( Expanded(
child: FilledButton( child: FilledButton(
onPressed: _currentStep < 4 onPressed: _currentStep < 4
? (canGoNext ? () => setState(() => _currentStep++) : null) ? (canGoNext
: (_isLoading ? null : _handleSignup), ? () => setState(() => _currentStep++)
: null)
: (_isLoading ? null : _handleSignup),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(55), minimumSize: const Size.fromHeight(55),
backgroundColor: Colors.black, backgroundColor: Colors.black,
), ),
child: _isLoading child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) ? const SizedBox(
: Text( height: 20,
_currentStep < 4 width: 20,
? tr('ui.userfront.signup.next_step', fallback: '다음 단계') child: CircularProgressIndicator(
: tr('ui.userfront.signup.complete', fallback: '가입 완료'), color: Colors.white,
), strokeWidth: 2,
),
)
: Text(
_currentStep < 4
? tr(
'ui.userfront.signup.next_step',
fallback: '다음 단계',
)
: tr(
'ui.userfront.signup.complete',
fallback: '가입 완료',
),
),
), ),
), ),
], ],

View File

@@ -18,22 +18,19 @@ String _envOrDefault(String key, String fallback) {
String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async { Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
final queryParameters = <String, String>{ final queryParameters = <String, String>{'limit': '20'};
'limit': '20',
};
if (cursor != null && cursor.isNotEmpty) { if (cursor != null && cursor.isNotEmpty) {
queryParameters['cursor'] = cursor; queryParameters['cursor'] = cursor;
} }
final url = Uri.parse('$_baseUrl/api/v1/audit/auth/timeline') final url = Uri.parse(
.replace(queryParameters: queryParameters); '$_baseUrl/api/v1/audit/auth/timeline',
).replace(queryParameters: queryParameters);
final useCookie = AuthTokenStore.usesCookie(); final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken(); final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{ final headers = <String, String>{'Content-Type': 'application/json'};
'Content-Type': 'application/json',
};
if (!useCookie && token != null) { if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }
@@ -60,10 +57,6 @@ Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
} }
} }
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor}); typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) { final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) {
@@ -86,11 +79,11 @@ class AuthTimelineState {
}); });
const AuthTimelineState.initial() const AuthTimelineState.initial()
: items = const [], : items = const [],
nextCursor = null, nextCursor = null,
isLoading = false, isLoading = false,
isLoadingMore = false, isLoadingMore = false,
error = null; error = null;
AuthTimelineState copyWith({ AuthTimelineState copyWith({
List<AuditLogEntry>? items, List<AuditLogEntry>? items,
@@ -188,6 +181,7 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
} }
} }
final authTimelineProvider = NotifierProvider<AuthTimelineNotifier, AuthTimelineState>( final authTimelineProvider =
AuthTimelineNotifier.new, NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
); AuthTimelineNotifier.new,
);

View File

@@ -69,9 +69,7 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
final token = AuthTokenStore.getToken(); final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{ final headers = <String, String>{'Content-Type': 'application/json'};
'Content-Type': 'application/json',
};
if (!useCookie && token != null) { if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }
@@ -106,6 +104,7 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
} }
} }
final linkedRpsProvider = AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() { final linkedRpsProvider =
return LinkedRpsNotifier(); AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
}); return LinkedRpsNotifier();
});

View File

@@ -68,8 +68,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
content: Text( content: Text(
tr( tr(
'msg.userfront.dashboard.revoke.confirm', 'msg.userfront.dashboard.revoke.confirm',
fallback: fallback: '{{app}} 앱과의 연동을 해지하시겠습니까?\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.',
'{{app}} 앱과의 연동을 해지하시겠습니까?\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.',
params: {'app': appName}, params: {'app': appName},
), ),
), ),
@@ -81,8 +80,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red), style: TextButton.styleFrom(foregroundColor: Colors.red),
child: child: Text(
Text(tr('ui.userfront.dashboard.revoke.confirm_button', fallback: '해지하기')), tr(
'ui.userfront.dashboard.revoke.confirm_button',
fallback: '해지하기',
),
),
), ),
], ],
), ),
@@ -158,31 +161,45 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
tr('ui.userfront.dashboard.scopes.title', tr(
fallback: '권한 (Scopes)'), 'ui.userfront.dashboard.scopes.title',
fallback: '권한 (Scopes)',
),
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (item.scopes.isEmpty) if (item.scopes.isEmpty)
Text( Text(
tr('msg.userfront.dashboard.scopes.empty', tr(
fallback: '요청된 권한이 없습니다.'), 'msg.userfront.dashboard.scopes.empty',
fallback: '요청된 권한이 없습니다.',
),
style: const TextStyle(color: Colors.grey), style: const TextStyle(color: Colors.grey),
) )
else else
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 4, runSpacing: 4,
children: item.scopes.map((s) => Chip( children: item.scopes
label: Text(s, style: const TextStyle(fontSize: 12)), .map(
visualDensity: VisualDensity.compact, (s) => Chip(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, label: Text(
)).toList(), s,
style: const TextStyle(fontSize: 12),
),
visualDensity: VisualDensity.compact,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
)
.toList(),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
tr('ui.userfront.dashboard.status_history', tr(
fallback: '상태 이력'), 'ui.userfront.dashboard.status_history',
fallback: '상태 이력',
),
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -200,10 +217,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Builder( Builder(
builder: (context) { builder: (context) {
final statusLabel = item.status == 'active' final statusLabel = item.status == 'active'
? tr('ui.common.status.active', ? tr('ui.common.status.active', fallback: '활성')
fallback: '활성') : tr(
: tr('ui.userfront.dashboard.status.revoked', 'ui.userfront.dashboard.status.revoked',
fallback: '해지됨'); fallback: '해지됨',
);
return Text( return Text(
tr( tr(
'msg.userfront.dashboard.current_status', 'msg.userfront.dashboard.current_status',
@@ -242,8 +260,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [ children: [
ListTile( ListTile(
leading: const Icon(Icons.home_outlined), leading: const Icon(Icons.home_outlined),
title: title: Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
selected: true, selected: true,
onTap: () { onTap: () {
if (closeOnTap) { if (closeOnTap) {
@@ -254,8 +271,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
ListTile( ListTile(
leading: const Icon(Icons.person_outline), leading: const Icon(Icons.person_outline),
title: title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
onTap: () { onTap: () {
if (closeOnTap) { if (closeOnTap) {
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -265,8 +281,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
ListTile( ListTile(
leading: const Icon(Icons.qr_code_scanner), leading: const Icon(Icons.qr_code_scanner),
title: title: Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
onTap: () { onTap: () {
if (closeOnTap) { if (closeOnTap) {
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -277,8 +292,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const Divider(), const Divider(),
ListTile( ListTile(
leading: const Icon(Icons.logout), leading: const Icon(Icons.logout),
title: title: Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
onTap: () async { onTap: () async {
if (closeOnTap) { if (closeOnTap) {
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -316,21 +330,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Future<AuditPage> _fetchAuditLogs({String? cursor}) async { Future<AuditPage> _fetchAuditLogs({String? cursor}) async {
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
final queryParameters = <String, String>{ final queryParameters = <String, String>{'limit': '20'};
'limit': '20',
};
if (cursor != null && cursor.isNotEmpty) { if (cursor != null && cursor.isNotEmpty) {
queryParameters['cursor'] = cursor; queryParameters['cursor'] = cursor;
} }
final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline') final url = Uri.parse(
.replace(queryParameters: queryParameters); '$baseUrl/api/v1/audit/auth/timeline',
).replace(queryParameters: queryParameters);
final useCookie = AuthTokenStore.usesCookie(); final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken(); final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{ final headers = <String, String>{'Content-Type': 'application/json'};
'Content-Type': 'application/json',
};
if (!useCookie && token != null) { if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }
@@ -401,11 +412,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
if (parts.length != 3) { if (parts.length != 3) {
return null; return null;
} }
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))); final payload = utf8.decode(
base64Url.decode(base64Url.normalize(parts[1])),
);
final data = json.decode(payload) as Map<String, dynamic>; final data = json.decode(payload) as Map<String, dynamic>;
final iatValue = data['iat'] ?? data['auth_time']; final iatValue = data['iat'] ?? data['auth_time'];
if (iatValue is num) { if (iatValue is num) {
return DateTime.fromMillisecondsSinceEpoch(iatValue.toInt() * 1000).toLocal(); return DateTime.fromMillisecondsSinceEpoch(
iatValue.toInt() * 1000,
).toLocal();
} }
} catch (_) { } catch (_) {
return null; return null;
@@ -467,9 +482,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) { Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) {
final isOidc = authMethod.contains('OIDC'); final isOidc = authMethod.contains('OIDC');
if (authMethod != 'QR' && !isOidc) { if (authMethod != 'QR' && !isOidc) {
final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? ''; final approvedUserAgent =
log.detailMap['approved_user_agent']?.toString() ?? '';
final approvedIp = log.detailMap['approved_ip']?.toString() ?? ''; final approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty; final hasApproverMeta =
approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
if (!authMethod.startsWith('링크') || !hasApproverMeta) { if (!authMethod.startsWith('링크') || !hasApproverMeta) {
return _selectableText(authMethod); return _selectableText(authMethod);
} }
@@ -497,7 +514,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
); );
} }
final approvedSessionId = (log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ?? false) final approvedSessionId =
(log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ??
false)
? log.detailMap['approved_session_id'].toString() ? log.detailMap['approved_session_id'].toString()
: log.sessionId; : log.sessionId;
final tooltipLabel = isOidc final tooltipLabel = isOidc
@@ -544,8 +563,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'), isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'),
style: TextStyle( style: TextStyle(
color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent, color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent,
decoration: decoration: approvedSessionId.isEmpty
approvedSessionId.isEmpty ? null : TextDecoration.underline, ? null
: TextDecoration.underline,
), ),
), ),
), ),
@@ -555,9 +575,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) { Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) {
final isOidc = authMethod.contains('OIDC'); final isOidc = authMethod.contains('OIDC');
if (authMethod != 'QR' && !isOidc) { if (authMethod != 'QR' && !isOidc) {
final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? ''; final approvedUserAgent =
log.detailMap['approved_user_agent']?.toString() ?? '';
final approvedIp = log.detailMap['approved_ip']?.toString() ?? ''; final approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty; final hasApproverMeta =
approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
if (!authMethod.startsWith('링크') || !hasApproverMeta) { if (!authMethod.startsWith('링크') || !hasApproverMeta) {
return _selectableText( return _selectableText(
tr( tr(
@@ -595,7 +617,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
); );
} }
final approvedSessionId = (log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ?? false) final approvedSessionId =
(log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ??
false)
? log.detailMap['approved_session_id'].toString() ? log.detailMap['approved_session_id'].toString()
: log.sessionId; : log.sessionId;
final tooltipLabel = isOidc final tooltipLabel = isOidc
@@ -642,7 +666,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
'msg.userfront.dashboard.auth_method', 'msg.userfront.dashboard.auth_method',
fallback: '인증수단: {{method}}', fallback: '인증수단: {{method}}',
params: { params: {
'method': isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'), 'method': isOidc
? authMethod
: tr('ui.common.qr', fallback: 'QR'),
}, },
), ),
style: TextStyle( style: TextStyle(
@@ -714,7 +740,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final profileState = ref.watch(profileProvider); final profileState = ref.watch(profileProvider);
final profile = profileState.value; final profile = profileState.value;
final timelineState = ref.watch(authTimelineProvider); final timelineState = ref.watch(authTimelineProvider);
final userName = profile?.name ?? final userName =
profile?.name ??
profile?.email ?? profile?.email ??
profile?.phone ?? profile?.phone ??
tr('ui.userfront.profile.user_fallback', fallback: 'User'); tr('ui.userfront.profile.user_fallback', fallback: 'User');
@@ -751,7 +778,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
], ],
), ),
drawer: isWide ? null : Drawer(child: _buildSideMenu(context, closeOnTap: true)), drawer: isWide
? null
: Drawer(child: _buildSideMenu(context, closeOnTap: true)),
body: Row( body: Row(
children: [ children: [
if (isWide) if (isWide)
@@ -775,11 +804,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (!isMobile) ...[ if (!isMobile) ...[
_buildHeaderCard(userName, department, sessionIssuedAt), _buildHeaderCard(
userName,
department,
sessionIssuedAt,
),
const SizedBox(height: 28), const SizedBox(height: 28),
], ],
_buildSectionTitle( _buildSectionTitle(
tr('ui.userfront.sections.apps', fallback: '나의 App 현황'), tr(
'ui.userfront.sections.apps',
fallback: '나의 App 현황',
),
tr( tr(
'msg.userfront.sections.apps_subtitle', 'msg.userfront.sections.apps_subtitle',
fallback: '현재 연결된 앱과 최근 인증 상태입니다.', fallback: '현재 연결된 앱과 최근 인증 상태입니다.',
@@ -810,7 +846,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
); );
} }
Widget _buildHeaderCard(String userName, String department, DateTime? issuedAt) { Widget _buildHeaderCard(
String userName,
String department,
DateTime? issuedAt,
) {
final sessionLabel = issuedAt != null final sessionLabel = issuedAt != null
? _formatDateTime(issuedAt) ? _formatDateTime(issuedAt)
: tr('ui.userfront.session.unknown', fallback: '알 수 없음'); : tr('ui.userfront.session.unknown', fallback: '알 수 없음');
@@ -823,7 +863,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
fallback: '안녕하세요, {{name}}님', fallback: '안녕하세요, {{name}}님',
params: {'name': userName}, params: {'name': userName},
), ),
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: _ink), style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: _ink,
),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
@@ -871,13 +915,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [ children: [
Text( Text(
title, title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _ink), style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _ink,
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
subtitle,
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
),
], ],
); );
} }
@@ -897,7 +942,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
label, label,
style: const TextStyle(fontSize: 12, color: _ink, fontWeight: FontWeight.w600), style: const TextStyle(
fontSize: 12,
color: _ink,
fontWeight: FontWeight.w600,
),
), ),
], ],
), ),
@@ -920,7 +969,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
'msg.userfront.dashboard.activities.empty', 'msg.userfront.dashboard.activities.empty',
fallback: '연동된 앱이 없습니다.', fallback: '연동된 앱이 없습니다.',
), ),
style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600), style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
@@ -959,14 +1012,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
); );
} }
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) { List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
final items = <_ActivityItem>[]; final items = <_ActivityItem>[];
for (final rp in linkedRps) { for (final rp in linkedRps) {
final normalizedStatus = rp.status.toLowerCase(); final normalizedStatus = rp.status.toLowerCase();
// status가 'inactive'로 내려올 수 있으므로 이를 반영 // status가 'inactive'로 내려올 수 있으므로 이를 반영
final isActiveInApi = normalizedStatus == 'active' || normalizedStatus == ''; final isActiveInApi =
normalizedStatus == 'active' || normalizedStatus == '';
final isRevoked = !isActiveInApi; final isRevoked = !isActiveInApi;
final lastAuthLabel = rp.lastAuthenticatedAt != null final lastAuthLabel = rp.lastAuthenticatedAt != null
@@ -1042,7 +1094,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
// 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려) // 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려)
final double spacing = 12.0; final double spacing = 12.0;
final double cardWidth = (maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount; final double cardWidth =
(maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1063,18 +1116,24 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Align( child: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton.icon( child: TextButton.icon(
onPressed: () => setState(() => _showAllActivities = !_showAllActivities), onPressed: () => setState(
() => _showAllActivities = !_showAllActivities,
),
icon: Icon( icon: Icon(
_showAllActivities ? Icons.keyboard_arrow_up : Icons.add, _showAllActivities ? Icons.keyboard_arrow_up : Icons.add,
size: 18, size: 18,
color: _showAllActivities ? Colors.grey : Colors.blueAccent, color: _showAllActivities
? Colors.grey
: Colors.blueAccent,
), ),
label: Text( label: Text(
_showAllActivities _showAllActivities
? tr('ui.common.collapse', fallback: '접기') ? tr('ui.common.collapse', fallback: '접기')
: tr('ui.common.show_more', fallback: '+ 더보기'), : tr('ui.common.show_more', fallback: '+ 더보기'),
style: TextStyle( style: TextStyle(
color: _showAllActivities ? Colors.grey : Colors.blueAccent, color: _showAllActivities
? Colors.grey
: Colors.blueAccent,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -1090,7 +1149,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) { Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) {
final isActive = item.status == 'active'; final isActive = item.status == 'active';
final statusColor = isActive ? Colors.green : Colors.grey; final statusColor = isActive ? Colors.green : Colors.grey;
final borderColor = isActive ? Colors.green.withValues(alpha: 128) : _border; final borderColor = isActive
? Colors.green.withValues(alpha: 128)
: _border;
final borderWidth = isActive ? 1.5 : 1.0; final borderWidth = isActive ? 1.5 : 1.0;
// 활성 상태면 클릭 가능 (URL 유무와 관계없이) // 활성 상태면 클릭 가능 (URL 유무와 관계없이)
@@ -1104,13 +1165,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
color: _surface, color: _surface,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
border: Border.all(color: borderColor, width: borderWidth), border: Border.all(color: borderColor, width: borderWidth),
boxShadow: isActive ? [ boxShadow: isActive
BoxShadow( ? [
color: Colors.green.withValues(alpha: 13), BoxShadow(
blurRadius: 10, color: Colors.green.withValues(alpha: 13),
offset: const Offset(0, 4), blurRadius: 10,
) offset: const Offset(0, 4),
] : null, ),
]
: null,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1120,7 +1183,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Expanded( Expanded(
child: Text( child: Text(
item.appName, item.appName,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _ink), style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _ink,
),
), ),
), ),
Container( Container(
@@ -1132,8 +1199,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Text( child: Text(
item.status == 'active' item.status == 'active'
? tr('ui.common.status.active', fallback: '활성') ? tr('ui.common.status.active', fallback: '활성')
: tr('ui.userfront.dashboard.status.revoked', fallback: '해지됨'), : tr(
style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w600), 'ui.userfront.dashboard.status.revoked',
fallback: '해지됨',
),
style: TextStyle(
fontSize: 11,
color: statusColor,
fontWeight: FontWeight.w600,
),
), ),
), ),
], ],
@@ -1146,7 +1220,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
item.lastAuthAt, item.lastAuthAt,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: _ink), style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _ink,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
@@ -1168,22 +1246,38 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: OutlinedButton( child: OutlinedButton(
onPressed: (_isRevoking || item.isRevoked) ? null : item.onRevoke, onPressed: (_isRevoking || item.isRevoked)
? null
: item.onRevoke,
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: item.isRevoked ? Colors.grey : Colors.redAccent, foregroundColor: item.isRevoked
side: BorderSide(color: item.isRevoked ? Colors.grey : Colors.redAccent, width: 0.5), ? Colors.grey
: Colors.redAccent,
side: BorderSide(
color: item.isRevoked ? Colors.grey : Colors.redAccent,
width: 0.5,
),
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
), ),
child: _isRevoking && !item.isRevoked child: _isRevoking && !item.isRevoked
? const SizedBox( ? const SizedBox(
width: 14, width: 14,
height: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.redAccent), child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.redAccent,
),
) )
: Text( : Text(
item.isRevoked item.isRevoked
? tr('ui.userfront.dashboard.status.revoked', fallback: '해지됨') ? tr(
: tr('ui.userfront.dashboard.revoke.title', fallback: '연동 해지'), 'ui.userfront.dashboard.status.revoked',
fallback: '해지됨',
)
: tr(
'ui.userfront.dashboard.revoke.title',
fallback: '연동 해지',
),
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
), ),
@@ -1268,7 +1362,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TextButton( TextButton(
onPressed: () => ref.read(authTimelineProvider.notifier).refresh(), onPressed: () =>
ref.read(authTimelineProvider.notifier).refresh(),
child: Text(tr('ui.common.retry', fallback: '다시 시도')), child: Text(tr('ui.common.retry', fallback: '다시 시도')),
), ),
], ],
@@ -1326,7 +1421,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
columns: [ columns: [
DataColumn( DataColumn(
label: Text( label: Text(
tr('ui.userfront.audit.table.session_id', fallback: 'Session ID'), tr(
'ui.userfront.audit.table.session_id',
fallback: 'Session ID',
),
), ),
), ),
DataColumn( DataColumn(
@@ -1336,7 +1434,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
DataColumn( DataColumn(
label: Text( label: Text(
tr('ui.userfront.audit.table.app', fallback: '애플리케이션'), tr(
'ui.userfront.audit.table.app',
fallback: '애플리케이션',
),
), ),
), ),
DataColumn( DataColumn(
@@ -1346,17 +1447,26 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
DataColumn( DataColumn(
label: Text( label: Text(
tr('ui.userfront.audit.table.device', fallback: '접속환경'), tr(
'ui.userfront.audit.table.device',
fallback: '접속환경',
),
), ),
), ),
DataColumn( DataColumn(
label: Text( label: Text(
tr('ui.userfront.audit.table.auth_method', fallback: '인증수단'), tr(
'ui.userfront.audit.table.auth_method',
fallback: '인증수단',
),
), ),
), ),
DataColumn( DataColumn(
label: Text( label: Text(
tr('ui.userfront.audit.table.result', fallback: '인증결과'), tr(
'ui.userfront.audit.table.result',
fallback: '인증결과',
),
), ),
), ),
DataColumn( DataColumn(
@@ -1369,47 +1479,57 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final statusLabel = log.status == 'success' final statusLabel = log.status == 'success'
? tr('ui.common.status.success', fallback: '성공') ? tr('ui.common.status.success', fallback: '성공')
: tr('ui.common.status.failure', fallback: '실패'); : tr('ui.common.status.failure', fallback: '실패');
final statusColor = final statusColor = log.status == 'success'
log.status == 'success' ? Colors.green : Colors.redAccent; ? Colors.green
: Colors.redAccent;
final authMethod = log.authMethod.isNotEmpty final authMethod = log.authMethod.isNotEmpty
? log.authMethod ? log.authMethod
: _authMethodLabel(); : _authMethodLabel();
final deviceLabel = _deviceLabelFromUserAgent(log.userAgent); final deviceLabel = _deviceLabelFromUserAgent(
return DataRow(cells: [ log.userAgent,
DataCell( );
_selectableText( return DataRow(
log.sessionId.isEmpty cells: [
? tr('ui.common.hyphen', fallback: '-') DataCell(
: log.sessionId, _selectableText(
), log.sessionId.isEmpty
), ? tr('ui.common.hyphen', fallback: '-')
DataCell(_selectableText(_formatDateTime(log.timestamp))), : log.sessionId,
DataCell(_buildAppCell(log)),
DataCell(
_selectableText(
log.ipAddress.isEmpty
? tr('ui.common.hyphen', fallback: '-')
: log.ipAddress,
),
),
DataCell(_selectableText(deviceLabel)),
DataCell(_buildAuthMethodCell(log, authMethod)),
DataCell(
_selectableText(
statusLabel,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w600,
), ),
), ),
), DataCell(
DataCell( _selectableText(_formatDateTime(log.timestamp)),
_selectableText(
tr('ui.userfront.audit.table.pending', fallback: '(준비중)'),
style: const TextStyle(color: Colors.grey),
), ),
), DataCell(_buildAppCell(log)),
]); DataCell(
_selectableText(
log.ipAddress.isEmpty
? tr('ui.common.hyphen', fallback: '-')
: log.ipAddress,
),
),
DataCell(_selectableText(deviceLabel)),
DataCell(_buildAuthMethodCell(log, authMethod)),
DataCell(
_selectableText(
statusLabel,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
),
DataCell(
_selectableText(
tr(
'ui.userfront.audit.table.pending',
fallback: '(준비중)',
),
style: const TextStyle(color: Colors.grey),
),
),
],
);
}).toList(), }).toList(),
), ),
), ),
@@ -1443,7 +1563,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Expanded( Expanded(
child: _buildAppCell( child: _buildAppCell(
log, log,
style: const TextStyle(fontWeight: FontWeight.w600, color: _ink), style: const TextStyle(
fontWeight: FontWeight.w600,
color: _ink,
),
), ),
), ),
_selectableText( _selectableText(
@@ -1451,7 +1574,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
? tr('ui.common.status.success', fallback: '성공') ? tr('ui.common.status.success', fallback: '성공')
: tr('ui.common.status.failure', fallback: '실패'), : tr('ui.common.status.failure', fallback: '실패'),
style: TextStyle( style: TextStyle(
color: log.status == 'success' ? Colors.green : Colors.redAccent, color: log.status == 'success'
? Colors.green
: Colors.redAccent,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
@@ -1491,10 +1616,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
tr( tr(
'msg.userfront.audit.device', 'msg.userfront.audit.device',
fallback: '접속환경: {{value}}', fallback: '접속환경: {{value}}',
params: {'value': _deviceLabelFromUserAgent(log.userAgent)}, params: {
'value': _deviceLabelFromUserAgent(log.userAgent),
},
), ),
), ),
_buildAuthMethodLine(log, log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel()), _buildAuthMethodLine(
log,
log.authMethod.isNotEmpty
? log.authMethod
: _authMethodLabel(),
),
_selectableText( _selectableText(
tr( tr(
'msg.userfront.audit.result', 'msg.userfront.audit.result',
@@ -1507,10 +1639,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
), ),
_selectableText( _selectableText(
tr( tr('msg.userfront.audit.status', fallback: '현황: (준비중)'),
'msg.userfront.audit.status',
fallback: '현황: (준비중)',
),
style: TextStyle(color: Colors.grey[600]), style: TextStyle(color: Colors.grey[600]),
), ),
], ],
@@ -1542,7 +1671,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
), ),
TextButton( TextButton(
onPressed: () => ref.read(authTimelineProvider.notifier).loadMore(), onPressed: () =>
ref.read(authTimelineProvider.notifier).loadMore(),
child: Text(tr('ui.common.retry', fallback: '재시도')), child: Text(tr('ui.common.retry', fallback: '재시도')),
), ),
], ],

View File

@@ -21,12 +21,7 @@ class Tenant {
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {'id': id, 'name': name, 'slug': slug, 'description': description};
'id': id,
'name': name,
'slug': slug,
'description': description,
};
} }
} }
@@ -62,7 +57,9 @@ class UserProfile {
department: json['department'] ?? '', department: json['department'] ?? '',
affiliationType: json['affiliationType'] ?? '', affiliationType: json['affiliationType'] ?? '',
companyCode: json['companyCode'] ?? '', companyCode: json['companyCode'] ?? '',
metadata: json['metadata'] != null ? Map<String, dynamic>.from(json['metadata']) : null, metadata: json['metadata'] != null
? Map<String, dynamic>.from(json['metadata'])
: null,
tenant: json['tenant'] != null ? Tenant.fromJson(json['tenant']) : null, tenant: json['tenant'] != null ? Tenant.fromJson(json['tenant']) : null,
); );
} }
@@ -81,11 +78,7 @@ class UserProfile {
}; };
} }
UserProfile copyWith({ UserProfile copyWith({String? name, String? phone, String? department}) {
String? name,
String? phone,
String? department,
}) {
return UserProfile( return UserProfile(
id: id, id: id,
email: email, email: email,

View File

@@ -13,7 +13,8 @@ class ProfileRepository {
return dotenv.env[key] ?? fallback; return dotenv.env[key] ?? fallback;
} }
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); static String get _baseUrl =>
_envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
// Helper to get session token // Helper to get session token
static Future<String?> _getToken() async { static Future<String?> _getToken() async {
@@ -31,9 +32,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me'); final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{ final headers = <String, String>{'Content-Type': 'application/json'};
'Content-Type': 'application/json',
};
if (!useCookie && token != null) { if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }
@@ -68,9 +67,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me'); final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{ final headers = <String, String>{'Content-Type': 'application/json'};
'Content-Type': 'application/json',
};
if (!useCookie && token != null) { if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }
@@ -107,9 +104,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code'); final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{ final headers = <String, String>{'Content-Type': 'application/json'};
'Content-Type': 'application/json',
};
if (!useCookie && token != null) { if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }
@@ -145,9 +140,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me/password'); final url = Uri.parse('$_baseUrl/api/v1/user/me/password');
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{ final headers = <String, String>{'Content-Type': 'application/json'};
'Content-Type': 'application/json',
};
if (!useCookie && token != null) { if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }
@@ -183,9 +176,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code'); final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{ final headers = <String, String>{'Content-Type': 'application/json'};
'Content-Type': 'application/json',
};
if (!useCookie && token != null) { if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }

View File

@@ -35,17 +35,17 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
// Perform update and then re-fetch profile // Perform update and then re-fetch profile
state = await AsyncValue.guard(() async { state = await AsyncValue.guard(() async {
await ref.read(profileRepositoryProvider).updateMyProfile( await ref
name: name, .read(profileRepositoryProvider)
phone: phone, .updateMyProfile(name: name, phone: phone, department: department);
department: department,
);
return _fetch(); return _fetch();
}); });
} }
} }
// 3. Provider definition // 3. Provider definition
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(() { final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(
return ProfileNotifier(); () {
}); return ProfileNotifier();
},
);

View File

@@ -140,7 +140,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (_editingField != 'name' && _nameController!.text != profile.name) { if (_editingField != 'name' && _nameController!.text != profile.name) {
_nameController!.text = profile.name; _nameController!.text = profile.name;
} }
if (_editingField != 'department' && _departmentController!.text != profile.department) { if (_editingField != 'department' &&
_departmentController!.text != profile.department) {
_departmentController!.text = profile.department; _departmentController!.text = profile.department;
} }
if (_editingField != 'phone' && _phoneController!.text != profile.phone) { if (_editingField != 'phone' && _phoneController!.text != profile.phone) {
@@ -274,10 +275,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
tr( tr('msg.userfront.profile.phone.verified', fallback: '인증되었습니다.'),
'msg.userfront.profile.phone.verified',
fallback: '인증되었습니다.',
),
), ),
), ),
); );
@@ -310,24 +308,30 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
final confirmPassword = _confirmPasswordController?.text.trim() ?? ''; final confirmPassword = _confirmPasswordController?.text.trim() ?? '';
if (currentPassword.isEmpty) { if (currentPassword.isEmpty) {
setState(() => _passwordError = tr( setState(
'msg.userfront.profile.password.current_required', () => _passwordError = tr(
fallback: '현재 비밀번호를 입력해 주세요.', 'msg.userfront.profile.password.current_required',
)); fallback: '현재 비밀번호를 입력해 주세요.',
),
);
return; return;
} }
if (newPassword.isEmpty) { if (newPassword.isEmpty) {
setState(() => _passwordError = tr( setState(
'msg.userfront.profile.password.new_required', () => _passwordError = tr(
fallback: '새 비밀번호를 입력해 주세요.', 'msg.userfront.profile.password.new_required',
)); fallback: '새 비밀번호를 입력해 주세요.',
),
);
return; return;
} }
if (newPassword != confirmPassword) { if (newPassword != confirmPassword) {
setState(() => _passwordError = tr( setState(
'msg.userfront.profile.password.mismatch', () => _passwordError = tr(
fallback: '새 비밀번호가 일치하지 않습니다.', 'msg.userfront.profile.password.mismatch',
)); fallback: '새 비밀번호가 일치하지 않습니다.',
),
);
return; return;
} }
@@ -338,7 +342,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}); });
try { try {
await ref.read(profileRepositoryProvider).changePassword( await ref
.read(profileRepositoryProvider)
.changePassword(
currentPassword: currentPassword, currentPassword: currentPassword,
newPassword: newPassword, newPassword: newPassword,
); );
@@ -434,10 +440,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
tr( tr('msg.userfront.profile.name_required', fallback: '이름을 입력해주세요.'),
'msg.userfront.profile.name_required',
fallback: '이름을 입력해주세요.',
),
), ),
), ),
); );
@@ -500,7 +503,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_isSavingField = true; _isSavingField = true;
try { try {
await ref.read(profileProvider.notifier).updateProfile( await ref
.read(profileProvider.notifier)
.updateProfile(
name: nextName, name: nextName,
phone: nextPhone, phone: nextPhone,
department: nextDepartment, department: nextDepartment,
@@ -551,32 +556,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [ children: [
ListTile( ListTile(
leading: const Icon(Icons.home_outlined), leading: const Icon(Icons.home_outlined),
title: Text( title: Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
tr('ui.userfront.nav.dashboard', fallback: '대시보드'),
),
onTap: () => context.go('/'), onTap: () => context.go('/'),
), ),
ListTile( ListTile(
leading: const Icon(Icons.person_outline), leading: const Icon(Icons.person_outline),
title: Text( title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
tr('ui.userfront.nav.profile', fallback: '내 정보'),
),
selected: true, selected: true,
onTap: () => context.go('/profile'), onTap: () => context.go('/profile'),
), ),
ListTile( ListTile(
leading: const Icon(Icons.qr_code_scanner), leading: const Icon(Icons.qr_code_scanner),
title: Text( title: Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔'),
),
onTap: () => context.go('/scan'), onTap: () => context.go('/scan'),
), ),
const Divider(), const Divider(),
ListTile( ListTile(
leading: const Icon(Icons.logout), leading: const Icon(Icons.logout),
title: Text( title: Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
tr('ui.userfront.nav.logout', fallback: '로그아웃'),
),
onTap: _logout, onTap: _logout,
), ),
], ],
@@ -589,13 +586,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [ children: [
Text( Text(
title, title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _ink), style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _ink,
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
subtitle,
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
),
], ],
); );
} }
@@ -615,7 +613,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
label, label,
style: const TextStyle(fontSize: 12, color: _ink, fontWeight: FontWeight.w600), style: const TextStyle(
fontSize: 12,
color: _ink,
fontWeight: FontWeight.w600,
),
), ),
], ],
), ),
@@ -650,10 +652,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
child: Row( child: Row(
children: [ children: [
const CircleAvatar( const CircleAvatar(radius: 32, child: Icon(Icons.person, size: 32)),
radius: 32,
child: Icon(Icons.person, size: 32),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
@@ -672,7 +671,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text(email, style: TextStyle(color: Colors.grey[600], fontSize: 14)), Text(
email,
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
const SizedBox(height: 12), const SizedBox(height: 12),
Wrap( Wrap(
spacing: 8, spacing: 8,
@@ -682,7 +684,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
Icons.badge_outlined, Icons.badge_outlined,
tr('ui.userfront.profile.manage', fallback: '프로필 관리'), tr('ui.userfront.profile.manage', fallback: '프로필 관리'),
), ),
_buildInfoChip(Icons.apartment, profile.tenant?.name ?? department), _buildInfoChip(
Icons.apartment,
profile.tenant?.name ?? department,
),
], ],
), ),
], ],
@@ -787,9 +792,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (!isEditing) { if (!isEditing) {
return ListTile( return ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text( title: Text(tr('ui.userfront.profile.phone.title', fallback: '전화번호')),
tr('ui.userfront.profile.phone.title', fallback: '전화번호'),
),
subtitle: Text(displayValue), subtitle: Text(displayValue),
trailing: TextButton( trailing: TextButton(
onPressed: isUpdating ? null : () => _startEditing('phone', profile), onPressed: isUpdating ? null : () => _startEditing('phone', profile),
@@ -918,7 +921,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon(_showCurrentPassword ? Icons.visibility_off : Icons.visibility), icon: Icon(
_showCurrentPassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () => setState(() { onPressed: () => setState(() {
_showCurrentPassword = !_showCurrentPassword; _showCurrentPassword = !_showCurrentPassword;
}), }),
@@ -936,7 +943,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon(_showNewPassword ? Icons.visibility_off : Icons.visibility), icon: Icon(
_showNewPassword ? Icons.visibility_off : Icons.visibility,
),
onPressed: () => setState(() { onPressed: () => setState(() {
_showNewPassword = !_showNewPassword; _showNewPassword = !_showNewPassword;
}), }),
@@ -954,7 +963,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon(_showConfirmPassword ? Icons.visibility_off : Icons.visibility), icon: Icon(
_showConfirmPassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () => setState(() { onPressed: () => setState(() {
_showConfirmPassword = !_showConfirmPassword; _showConfirmPassword = !_showConfirmPassword;
}), }),
@@ -963,10 +976,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
if (_passwordError != null) ...[ if (_passwordError != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(_passwordError!, style: const TextStyle(color: Colors.red)),
_passwordError!,
style: const TextStyle(color: Colors.red),
),
], ],
if (_passwordSuccess != null) ...[ if (_passwordSuccess != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -1037,7 +1047,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [ children: [
_buildEditableTile( _buildEditableTile(
field: 'name', field: 'name',
label: tr('ui.userfront.profile.field.name', fallback: '이름'), label: tr(
'ui.userfront.profile.field.name',
fallback: '이름',
),
value: profile.name, value: profile.name,
profile: profile, profile: profile,
isUpdating: isUpdating, isUpdating: isUpdating,
@@ -1045,7 +1058,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
const Divider(height: 24), const Divider(height: 24),
_buildReadOnlyTile( _buildReadOnlyTile(
tr('ui.userfront.profile.field.email', fallback: '이메일'), tr(
'ui.userfront.profile.field.email',
fallback: '이메일',
),
profile.email, profile.email,
), ),
const Divider(height: 24), const Divider(height: 24),
@@ -1055,7 +1071,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
_buildSectionTitle( _buildSectionTitle(
tr('ui.userfront.profile.section.organization', fallback: '조직 정보'), tr(
'ui.userfront.profile.section.organization',
fallback: '조직 정보',
),
tr( tr(
'msg.userfront.profile.section.organization', 'msg.userfront.profile.section.organization',
fallback: '소속 및 구분 정보입니다.', fallback: '소속 및 구분 정보입니다.',
@@ -1067,7 +1086,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [ children: [
_buildEditableTile( _buildEditableTile(
field: 'department', field: 'department',
label: tr('ui.userfront.profile.field.department', fallback: '소속'), label: tr(
'ui.userfront.profile.field.department',
fallback: '소속',
),
value: profile.department, value: profile.department,
profile: profile, profile: profile,
isUpdating: isUpdating, isUpdating: isUpdating,
@@ -1075,7 +1097,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
const Divider(height: 24), const Divider(height: 24),
_buildReadOnlyTile( _buildReadOnlyTile(
tr('ui.userfront.profile.field.affiliation', fallback: '구분'), tr(
'ui.userfront.profile.field.affiliation',
fallback: '구분',
),
profile.affiliationType, profile.affiliationType,
), ),
if (profile.tenant != null) ...[ if (profile.tenant != null) ...[
@@ -1091,7 +1116,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (profile.companyCode.isNotEmpty) ...[ if (profile.companyCode.isNotEmpty) ...[
const Divider(height: 24), const Divider(height: 24),
_buildReadOnlyTile( _buildReadOnlyTile(
tr('ui.userfront.profile.field.company_code', fallback: '회사코드'), tr(
'ui.userfront.profile.field.company_code',
fallback: '회사코드',
),
profile.companyCode, profile.companyCode,
), ),
], ],
@@ -1148,7 +1176,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: () => ref.read(profileProvider.notifier).loadProfile(), onPressed: () =>
ref.read(profileProvider.notifier).loadProfile(),
child: Text(tr('ui.common.retry', fallback: '재시도')), child: Text(tr('ui.common.retry', fallback: '재시도')),
), ),
], ],
@@ -1193,11 +1222,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)), drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
body: Row( body: Row(
children: [ children: [
if (isWide) if (isWide) SizedBox(width: 240, child: _buildSideMenu(context)),
SizedBox(
width: 240,
child: _buildSideMenu(context),
),
Expanded(child: _buildContent(profile, isUpdating)), Expanded(child: _buildContent(profile, isUpdating)),
], ],
), ),

View File

@@ -4,11 +4,7 @@ class ProfileInfoRow extends StatelessWidget {
final String label; final String label;
final String value; final String value;
const ProfileInfoRow({ const ProfileInfoRow({super.key, required this.label, required this.value});
super.key,
required this.label,
required this.value,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -25,11 +25,7 @@ String _formatTemplate(String template, Map<String, String>? params) {
return result; return result;
} }
String tr( String tr(String key, {String? fallback, Map<String, String>? params}) {
String key, {
String? fallback,
Map<String, String>? params,
}) {
final locale = _resolveLocale(); final locale = _resolveLocale();
final map = locale == 'en' ? enStrings : koStrings; final map = locale == 'en' ? enStrings : koStrings;
final value = map[key]; final value = map[key];

File diff suppressed because one or more lines are too long

View File

@@ -46,7 +46,9 @@ void main() async {
FlutterError.presentError(details); FlutterError.presentError(details);
_log.severe("FLUTTER_ERROR", details.exception, details.stack); _log.severe("FLUTTER_ERROR", details.exception, details.stack);
// Also send to backend if needed // Also send to backend if needed
AuthProxyService.logError("FLUTTER_ERROR: ${details.exception}\n${details.stack}"); AuthProxyService.logError(
"FLUTTER_ERROR: ${details.exception}\n${details.stack}",
);
}; };
PlatformDispatcher.instance.onError = (error, stack) { PlatformDispatcher.instance.onError = (error, stack) {
@@ -86,16 +88,17 @@ final _router = GoRouter(
return const DashboardScreen(); return const DashboardScreen();
}, },
), ),
GoRoute( GoRoute(path: '/profile', builder: (context, state) => const ProfilePage()),
path: '/profile',
builder: (context, state) => const ProfilePage(),
),
GoRoute( GoRoute(
path: '/signin', path: '/signin',
builder: (context, state) { builder: (context, state) {
final loginChallenge = state.uri.queryParameters['login_challenge']; final loginChallenge = state.uri.queryParameters['login_challenge'];
final redirectUrl = state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_url']; final redirectUrl =
_routerLogger.info("Navigating to /signin with login_challenge: $loginChallenge, redirect: $redirectUrl"); state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
_routerLogger.info(
"Navigating to /signin with login_challenge: $loginChallenge, redirect: $redirectUrl",
);
return LoginScreen( return LoginScreen(
key: state.pageKey, key: state.pageKey,
loginChallenge: loginChallenge, loginChallenge: loginChallenge,
@@ -106,12 +109,11 @@ final _router = GoRouter(
GoRoute( GoRoute(
path: '/login', path: '/login',
builder: (context, state) { builder: (context, state) {
final redirectUrl = state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_url']; final redirectUrl =
state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
_routerLogger.info("Navigating to /login, redirect: $redirectUrl"); _routerLogger.info("Navigating to /login, redirect: $redirectUrl");
return LoginScreen( return LoginScreen(key: state.pageKey, redirectUrl: redirectUrl);
key: state.pageKey,
redirectUrl: redirectUrl,
);
}, },
), ),
GoRoute( GoRoute(
@@ -120,7 +122,9 @@ final _router = GoRouter(
final consentChallenge = state.uri.queryParameters['consent_challenge']; final consentChallenge = state.uri.queryParameters['consent_challenge'];
if (consentChallenge == null) { if (consentChallenge == null) {
_routerLogger.warning("Consent screen loaded without a challenge."); _routerLogger.warning("Consent screen loaded without a challenge.");
return const Scaffold(body: Center(child: Text('Error: Consent challenge is missing.'))); return const Scaffold(
body: Center(child: Text('Error: Consent challenge is missing.')),
);
} }
_routerLogger.info("Navigating to /consent with challenge."); _routerLogger.info("Navigating to /consent with challenge.");
return ConsentScreen(consentChallenge: consentChallenge); return ConsentScreen(consentChallenge: consentChallenge);
@@ -257,21 +261,22 @@ final _router = GoRouter(
final path = state.uri.path; final path = state.uri.path;
// Public paths that don't require login // Public paths that don't require login
final isPublicPath = path == '/signin' || final isPublicPath =
path == '/signup' || path == '/signin' ||
path == '/login' || path == '/signup' ||
path == '/registration' || path == '/login' ||
path == '/verify' || path == '/registration' ||
path == '/verification' || path == '/verify' ||
path.startsWith('/verify/') || path == '/verification' ||
path == '/approve' || path.startsWith('/verify/') ||
path.startsWith('/ql/') || path == '/approve' ||
path == '/forgot-password' || path.startsWith('/ql/') ||
path == '/recovery' || path == '/forgot-password' ||
path == '/reset-password' || path == '/recovery' ||
path == '/error' || path == '/reset-password' ||
path == '/settings' || path == '/error' ||
path == '/consent'; // Consent page is public path == '/settings' ||
path == '/consent'; // Consent page is public
_routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn"); _routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");
@@ -282,7 +287,7 @@ final _router = GoRouter(
// If not logged in and trying to access a protected page, redirect to /signin // If not logged in and trying to access a protected page, redirect to /signin
if (!isLoggedIn) { if (!isLoggedIn) {
_routerLogger.info("Not logged in, redirecting to /signin"); _routerLogger.info("Not logged in, redirecting to /signin");
// Preserve OIDC challenge if present // Preserve OIDC challenge if present
final loginChallenge = state.uri.queryParameters['login_challenge']; final loginChallenge = state.uri.queryParameters['login_challenge'];
if (loginChallenge != null) { if (loginChallenge != null) {

View File

@@ -1,7 +1,7 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<!-- <!--
If you are serving your web app in a path other than the root, change the If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from. href value below to reflect the base path you are serving from.
@@ -14,29 +14,29 @@
This is a placeholder for base href that will be replaced by the value of This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`. the `--base-href` argument provided to `flutter build`.
--> -->
<base href="/" /> <base href="/" />
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta content="IE=Edge" http-equiv="X-UA-Compatible" /> <meta content="IE=Edge" http-equiv="X-UA-Compatible" />
<meta name="description" content="바론 SW 포털" /> <meta name="description" content="바론 SW 포털" />
<!-- iOS meta tags & icons --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" /> <meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Baron 로그인" /> <meta name="apple-mobile-web-app-title" content="Baron 로그인" />
<link rel="apple-touch-icon" href="icons/Icon-192.png" /> <link rel="apple-touch-icon" href="icons/Icon-192.png" />
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png" /> <link rel="icon" type="image/png" href="favicon.png" />
<title>Baron 로그인</title> <title>Baron 로그인</title>
<link rel="manifest" href="manifest.json" /> <link rel="manifest" href="manifest.json" />
<link <link
href="https://fonts.googleapis.com/icon?family=Material+Icons" href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet" rel="stylesheet"
/> />
</head> </head>
<body> <body>
<script src="flutter_bootstrap.js" async></script> <script src="flutter_bootstrap.js" async></script>
</body> </body>
</html> </html>

View File

@@ -1,35 +1,35 @@
{ {
"name": "Baron 로그인", "name": "Baron 로그인",
"short_name": "Baron 로그인", "short_name": "Baron 로그인",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#0175C2", "background_color": "#0175C2",
"theme_color": "#0175C2", "theme_color": "#0175C2",
"description": "Baron 로그인 사용자 포털.", "description": "Baron 로그인 사용자 포털.",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"prefer_related_applications": false, "prefer_related_applications": false,
"icons": [ "icons": [
{ {
"src": "icons/Icon-192.png", "src": "icons/Icon-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "icons/Icon-512.png", "src": "icons/Icon-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "icons/Icon-maskable-192.png", "src": "icons/Icon-maskable-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
}, },
{ {
"src": "icons/Icon-maskable-512.png", "src": "icons/Icon-maskable-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
} }
] ]
} }