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

@@ -12,26 +12,31 @@ import {
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.tenant_groups",
to: "/tenant-groups",
icon: LayoutGrid,
},
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 }, { label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
{ label: "ui.admin.nav.users", to: "/users", icon: Users }, { label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key }, { label: "ui.admin.nav.users", to: "/users", icon: Users },
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs }, { label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
]; ];
function AppLayout() { function AppLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const [theme, setTheme] = useState<"light" | "dark">(() => { const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme"); const stored = window.localStorage.getItem("admin_theme");
@@ -39,7 +44,9 @@ import {
}); });
const handleLogout = () => { const handleLogout = () => {
if (window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))) { if (
window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))
) {
window.localStorage.removeItem("admin_session"); window.localStorage.removeItem("admin_session");
navigate("/login"); navigate("/login");
} }
@@ -131,7 +138,9 @@ import {
</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) {
@@ -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>(
"/v1/admin/debug/check-permission",
{
params: { namespace, object, relation, subject }, 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">
@@ -57,7 +61,7 @@ function PermissionChecker() {
<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>
@@ -71,7 +75,7 @@ function PermissionChecker() {
<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">
@@ -79,7 +83,7 @@ function PermissionChecker() {
<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">
@@ -87,7 +91,7 @@ function PermissionChecker() {
<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>
@@ -103,15 +107,20 @@ function PermissionChecker() {
</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
? "bg-green-500/10 border-green-500/50 text-green-600"
: "bg-destructive/10 border-destructive/50 text-destructive"
}`}
>
{result.allowed ? ( {result.allowed ? (
<> <>
<CheckCircle2 size={48} /> <CheckCircle2 size={48} />
<div className="text-xl font-bold">Access ALLOWED</div> <div className="text-xl font-bold">Access ALLOWED</div>
<p className="text-sm opacity-80 text-center"> <p className="text-sm opacity-80 text-center">
. ( ) . (
)
</p> </p>
</> </>
) : ( ) : (

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,14 +98,19 @@ 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
@@ -144,7 +149,7 @@ function TenantGroupAdminsTab() {
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,23 +163,34 @@ 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 &&
usersQuery.data?.items.length === 0 && (
<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>
)} )}
{usersQuery.data?.items.filter(u => !adminsQuery.data?.some(a => a.id === u.id)).map((user) => ( {usersQuery.data?.items
.filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell> <TableCell>
<div className="font-medium">{user.name}</div> <div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">{user.email}</div> <div className="text-[10px] text-muted-foreground">
{user.email}
</div>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <Button

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,7 +117,8 @@ 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)]">

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>
@@ -38,7 +41,8 @@ function TenantGroupDetailPage() {
</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";

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 {
type TenantGroupSummary,
addTenantToGroup, addTenantToGroup,
removeTenantFromGroup,
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>
@@ -141,7 +146,7 @@ function TenantGroupTenantsTab() {
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>
@@ -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,7 +178,9 @@ 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]">

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,
fetchUsers,
removeTenantAdmin, removeTenantAdmin,
fetchUsers
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
function TenantAdminsTab() { function TenantAdminsTab() {
@@ -97,14 +97,19 @@ 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
@@ -143,7 +148,7 @@ function TenantAdminsTab() {
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,23 +162,34 @@ 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 &&
usersQuery.data?.items.length === 0 && (
<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>
)} )}
{usersQuery.data?.items.filter(u => !adminsQuery.data?.some(a => a.id === u.id)).map((user) => ( {usersQuery.data?.items
.filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell> <TableCell>
<div className="font-medium">{user.name}</div> <div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">{user.email}</div> <div className="text-[10px] text-muted-foreground">
{user.email}
</div>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <Button

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

@@ -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

@@ -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'];
} }
} }

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')));
} }
} }
} }
@@ -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

@@ -37,7 +37,10 @@ class ErrorScreen extends StatelessWidget {
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(
@@ -51,7 +54,10 @@ class ErrorScreen extends StatelessWidget {
: ((description?.isNotEmpty == true) : ((description?.isNotEmpty == true)
? description! ? description!
: (hasCode : (hasCode
? tr('msg.userfront.error.detail_generic', fallback: '오류가 발생했습니다.') ? tr(
'msg.userfront.error.detail_generic',
fallback: '오류가 발생했습니다.',
)
: tr( : tr(
'msg.userfront.error.detail_request', 'msg.userfront.error.detail_request',
fallback: '요청을 처리하는 중 문제가 발생했습니다.', fallback: '요청을 처리하는 중 문제가 발생했습니다.',
@@ -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,7 +379,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _startQrPolling() { void _startQrPolling() {
_qrPollingTimer?.cancel(); _qrPollingTimer?.cancel();
_qrPollingTimer = Timer.periodic(Duration(milliseconds: _qrPollIntervalMs), (timer) async { _qrPollingTimer = Timer.periodic(
Duration(milliseconds: _qrPollIntervalMs),
(timer) async {
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) { if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
timer.cancel(); timer.cancel();
return; return;
@@ -392,10 +413,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
timer.cancel(); timer.cancel();
_qrCountdownTimer?.cancel(); _qrCountdownTimer?.cancel();
_showError( _showError(
tr( tr('msg.userfront.login.qr_expired', fallback: 'QR 세션이 만료되었습니다.'),
'msg.userfront.login.qr_expired',
fallback: 'QR 세션이 만료되었습니다.',
),
); );
return; return;
} }
@@ -418,7 +436,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
} catch (e) { } catch (e) {
debugPrint("[QR] Polling error: $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!,
@@ -1574,7 +1651,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'ui.userfront.login.qr.remaining', 'ui.userfront.login.qr.remaining',
fallback: '남은 시간: {{time}}', fallback: '남은 시간: {{time}}',
params: { params: {
'time': _formatTime(_qrRemainingSeconds), 'time': _formatTime(
_qrRemainingSeconds,
),
}, },
) )
: tr( : tr(
@@ -1583,7 +1662,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
), ),
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;
@@ -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(
() => _emailError = tr(
'msg.userfront.signup.email.invalid', 'msg.userfront.signup.email.invalid',
fallback: '유효한 이메일 형식이 아닙니다.', 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(
() => _emailError = tr(
'msg.userfront.signup.email.duplicate', 'msg.userfront.signup.email.duplicate',
fallback: '이미 가입된 이메일입니다.', 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(
() => _emailError = tr(
'msg.userfront.signup.email.send_failed', 'msg.userfront.signup.email.send_failed',
fallback: '발송 실패: {{error}}', fallback: '발송 실패: {{error}}',
params: {'error': e.toString()}, 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(
() => _emailError = tr(
'msg.userfront.signup.email.code_mismatch', 'msg.userfront.signup.email.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.', fallback: '인증코드가 일치하지 않습니다.',
)); ),
);
} }
} catch (e) { } catch (e) {
setState(() => _emailError = tr( setState(
() => _emailError = tr(
'msg.userfront.signup.email.verify_failed', 'msg.userfront.signup.email.verify_failed',
fallback: '인증 실패: {{error}}', fallback: '인증 실패: {{error}}',
params: {'error': e.toString()}, 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(
() => _phoneError = tr(
'msg.userfront.signup.phone.send_failed', 'msg.userfront.signup.phone.send_failed',
fallback: '발송 실패: {{error}}', fallback: '발송 실패: {{error}}',
params: {'error': e.toString()}, 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(
() => _phoneError = tr(
'msg.userfront.signup.phone.code_mismatch', 'msg.userfront.signup.phone.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.', fallback: '인증코드가 일치하지 않습니다.',
)); ),
);
} }
} catch (e) { } catch (e) {
setState(() => _phoneError = tr( setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.verify_failed', 'msg.userfront.signup.phone.verify_failed',
fallback: '인증 실패: {{error}}', fallback: '인증 실패: {{error}}',
params: {'error': e.toString()}, 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(
() => _confirmPasswordError = tr(
'msg.userfront.signup.password.mismatch', 'msg.userfront.signup.password.mismatch',
fallback: '비밀번호가 일치하지 않습니다.', 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,
),
), ),
), ),
), ),
@@ -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') {
@@ -1317,7 +1375,9 @@ class _SignupScreenState extends State<SignupScreen> {
? _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,18 +1404,33 @@ class _SignupScreenState extends State<SignupScreen> {
Expanded( Expanded(
child: FilledButton( child: FilledButton(
onPressed: _currentStep < 4 onPressed: _currentStep < 4
? (canGoNext ? () => setState(() => _currentStep++) : null) ? (canGoNext
? () => setState(() => _currentStep++)
: null)
: (_isLoading ? null : _handleSignup), : (_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(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text( : Text(
_currentStep < 4 _currentStep < 4
? tr('ui.userfront.signup.next_step', fallback: '다음 단계') ? tr(
: tr('ui.userfront.signup.complete', fallback: '가입 완료'), '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) {
@@ -188,6 +181,7 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
} }
} }
final authTimelineProvider = NotifierProvider<AuthTimelineNotifier, AuthTimelineState>( final authTimelineProvider =
NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
AuthTimelineNotifier.new, 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 =
AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
return LinkedRpsNotifier(); 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(
(s) => Chip(
label: Text(
s,
style: const TextStyle(fontSize: 12),
),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize:
)).toList(), 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( BoxShadow(
color: Colors.green.withValues(alpha: 13), color: Colors.green.withValues(alpha: 13),
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), 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,13 +1479,17 @@ 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,
);
return DataRow(
cells: [
DataCell( DataCell(
_selectableText( _selectableText(
log.sessionId.isEmpty log.sessionId.isEmpty
@@ -1383,7 +1497,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
: log.sessionId, : log.sessionId,
), ),
), ),
DataCell(_selectableText(_formatDateTime(log.timestamp))), DataCell(
_selectableText(_formatDateTime(log.timestamp)),
),
DataCell(_buildAppCell(log)), DataCell(_buildAppCell(log)),
DataCell( DataCell(
_selectableText( _selectableText(
@@ -1405,11 +1521,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
DataCell( DataCell(
_selectableText( _selectableText(
tr('ui.userfront.audit.table.pending', fallback: '(준비중)'), tr(
'ui.userfront.audit.table.pending',
fallback: '(준비중)',
),
style: const TextStyle(color: Colors.grey), 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(
() => _passwordError = tr(
'msg.userfront.profile.password.current_required', 'msg.userfront.profile.password.current_required',
fallback: '현재 비밀번호를 입력해 주세요.', fallback: '현재 비밀번호를 입력해 주세요.',
)); ),
);
return; return;
} }
if (newPassword.isEmpty) { if (newPassword.isEmpty) {
setState(() => _passwordError = tr( setState(
() => _passwordError = tr(
'msg.userfront.profile.password.new_required', 'msg.userfront.profile.password.new_required',
fallback: '새 비밀번호를 입력해 주세요.', fallback: '새 비밀번호를 입력해 주세요.',
)); ),
);
return; return;
} }
if (newPassword != confirmPassword) { if (newPassword != confirmPassword) {
setState(() => _passwordError = tr( setState(
() => _passwordError = tr(
'msg.userfront.profile.password.mismatch', 'msg.userfront.profile.password.mismatch',
fallback: '새 비밀번호가 일치하지 않습니다.', 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,7 +261,8 @@ 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 == '/signin' ||
path == '/signup' || path == '/signup' ||
path == '/login' || path == '/login' ||
path == '/registration' || path == '/registration' ||