forked from baron/baron-sso
린트 적용
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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";
|
||||||
|
|||||||
@@ -4,134 +4,143 @@ import {
|
|||||||
Key,
|
Key,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
NotebookTabs,
|
NotebookTabs,
|
||||||
Rocket,
|
Rocket,
|
||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
Sun,
|
Sun,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import RoleSwitcher from "./RoleSwitcher";
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
||||||
{
|
{
|
||||||
label: "ui.admin.nav.tenant_dashboard",
|
label: "ui.admin.nav.tenant_dashboard",
|
||||||
to: "/dashboard",
|
to: "/dashboard",
|
||||||
icon: ShieldHalf,
|
icon: ShieldHalf,
|
||||||
},
|
},
|
||||||
{ label: "ui.admin.nav.tenant_groups", to: "/tenant-groups", icon: LayoutGrid },
|
{
|
||||||
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
|
label: "ui.admin.nav.tenant_groups",
|
||||||
{ label: "ui.admin.nav.users", to: "/users", icon: Users }, { label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
|
to: "/tenant-groups",
|
||||||
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
icon: LayoutGrid,
|
||||||
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
},
|
||||||
];
|
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
|
||||||
function AppLayout() {
|
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
|
||||||
const navigate = useNavigate();
|
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
|
||||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
||||||
const stored = window.localStorage.getItem("admin_theme");
|
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
||||||
return stored === "dark" ? "dark" : "light";
|
];
|
||||||
});
|
function AppLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||||
|
const stored = window.localStorage.getItem("admin_theme");
|
||||||
|
return stored === "dark" ? "dark" : "light";
|
||||||
|
});
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
if (window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))) {
|
if (
|
||||||
window.localStorage.removeItem("admin_session");
|
window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))
|
||||||
navigate("/login");
|
) {
|
||||||
}
|
window.localStorage.removeItem("admin_session");
|
||||||
};
|
navigate("/login");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const session = window.localStorage.getItem("admin_session");
|
const session = window.localStorage.getItem("admin_session");
|
||||||
if (!session) {
|
if (!session) {
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
}
|
}
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove("light", "dark");
|
root.classList.remove("light", "dark");
|
||||||
if (theme === "light") {
|
if (theme === "light") {
|
||||||
root.classList.add("light");
|
root.classList.add("light");
|
||||||
} else {
|
} else {
|
||||||
root.classList.add("dark");
|
root.classList.add("dark");
|
||||||
}
|
}
|
||||||
window.localStorage.setItem("admin_theme", theme);
|
window.localStorage.setItem("admin_theme", theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||||
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
||||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||||
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||||
<ShieldHalf size={20} />
|
<ShieldHalf size={20} />
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
|
||||||
{t("ui.admin.brand", "Baron 로그인")}
|
|
||||||
</p>
|
|
||||||
<h1 className="text-lg font-semibold">
|
|
||||||
{t("ui.admin.title", "Admin Control")}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
|
<div>
|
||||||
<BadgeCheck size={14} />
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
{t("msg.admin.scope_admin", "Scoped to /admin")}
|
{t("ui.admin.brand", "Baron 로그인")}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-lg font-semibold">
|
||||||
|
{t("ui.admin.title", "Admin Control")}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="px-2 pb-4 md:px-3 md:pb-8 h-[calc(100vh-200px)] flex flex-col justify-between">
|
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
|
||||||
<div className="space-y-1">
|
<BadgeCheck size={14} />
|
||||||
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
{t("msg.admin.scope_admin", "Scoped to /admin")}
|
||||||
<span className="rounded-full border border-border px-3 py-1">
|
</div>
|
||||||
{t("msg.admin.idp_env_prod", "IDP env: prod")}
|
</div>
|
||||||
</span>
|
<nav className="px-2 pb-4 md:px-3 md:pb-8 h-[calc(100vh-200px)] flex flex-col justify-between">
|
||||||
<span className="rounded-full border border-border px-3 py-1">
|
<div className="space-y-1">
|
||||||
{t("msg.admin.tenant_headers", "Tenant-aware headers")}
|
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
||||||
</span>
|
<span className="rounded-full border border-border px-3 py-1">
|
||||||
</div>
|
{t("msg.admin.idp_env_prod", "IDP env: prod")}
|
||||||
<div className="flex flex-col gap-1">
|
</span>
|
||||||
{navItems.map(({ label, to, icon: Icon }) => (
|
<span className="rounded-full border border-border px-3 py-1">
|
||||||
<NavLink
|
{t("msg.admin.tenant_headers", "Tenant-aware headers")}
|
||||||
key={to}
|
</span>
|
||||||
to={to}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
[
|
|
||||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
|
||||||
isActive
|
|
||||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
|
||||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
|
||||||
].join(" ")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon size={18} />
|
|
||||||
<span>{t(label, label)}</span>
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{navItems.map(({ label, to, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
[
|
||||||
|
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||||
|
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||||
|
].join(" ")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span>{t(label, label)}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="px-3 pt-4 border-t border-border/50">
|
<div className="px-3 pt-4 border-t border-border/50">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||||
>
|
>
|
||||||
<LogOut size={18} />
|
<LogOut size={18} />
|
||||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block"> <p>
|
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
|
||||||
|
{" "}
|
||||||
|
<p>
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.notice.scope",
|
"msg.admin.notice.scope",
|
||||||
"관리 기능은 /admin 네임스페이스에서만 노출합니다.",
|
"관리 기능은 /admin 네임스페이스에서만 노출합니다.",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ShieldHalf, LogIn, ExternalLink } from "lucide-react";
|
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -44,7 +44,7 @@ function LoginPage() {
|
|||||||
const popup = window.open(
|
const popup = window.open(
|
||||||
loginUrl,
|
loginUrl,
|
||||||
"BaronSSOLogin",
|
"BaronSSOLogin",
|
||||||
`width=${width},height=${height},top=${top},left=${left},status=no,menubar=no,toolbar=no`
|
`width=${width},height=${height},top=${top},left=${left},status=no,menubar=no,toolbar=no`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (popup) {
|
if (popup) {
|
||||||
@@ -78,8 +78,8 @@ function LoginPage() {
|
|||||||
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
|
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
|
||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-1">
|
||||||
<CardTitle className="text-2xl flex items-center gap-2">
|
<CardTitle className="text-2xl flex items-center gap-2">
|
||||||
<LogIn size={20} className="text-primary" />
|
<LogIn size={20} className="text-primary" />
|
||||||
관리자 로그인
|
관리자 로그인
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Baron 통합 인증(SSO)을 통해 관리자 페이지에 접속합니다.
|
Baron 통합 인증(SSO)을 통해 관리자 페이지에 접속합니다.
|
||||||
@@ -87,9 +87,9 @@ function LoginPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4 pb-8 space-y-3">
|
<CardContent className="pt-4 pb-8 space-y-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSSOLogin}
|
onClick={handleSSOLogin}
|
||||||
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
|
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
|
||||||
disabled={isLoggingIn}
|
disabled={isLoggingIn}
|
||||||
>
|
>
|
||||||
{isLoggingIn ? (
|
{isLoggingIn ? (
|
||||||
<>
|
<>
|
||||||
@@ -106,20 +106,22 @@ function LoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
|
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
|
||||||
관리자 전역 세션은 보안을 위해 15분간 유지됩니다.<br />
|
관리자 전역 세션은 보안을 위해 15분간 유지됩니다.
|
||||||
|
<br />
|
||||||
민감한 작업 시 재인증을 요구할 수 있습니다.
|
민감한 작업 시 재인증을 요구할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-center gap-4">
|
<div className="flex justify-center gap-4">
|
||||||
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
|
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||||
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
|
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||||
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
|
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||||
인증 정보가 없거나 로그인이 되지 않는 경우<br />
|
인증 정보가 없거나 로그인이 되지 않는 경우
|
||||||
|
<br />
|
||||||
시스템 관리자에게 문의하세요.
|
시스템 관리자에게 문의하세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { ShieldAlert, CheckCircle2, XCircle, Search } from "lucide-react";
|
import { CheckCircle2, Search, ShieldAlert, XCircle } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -31,9 +31,12 @@ function PermissionChecker() {
|
|||||||
|
|
||||||
const checkMutation = useMutation({
|
const checkMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const { data } = await apiClient.get<CheckPermissionResponse>("/v1/admin/debug/check-permission", {
|
const { data } = await apiClient.get<CheckPermissionResponse>(
|
||||||
params: { namespace, object, relation, subject },
|
"/v1/admin/debug/check-permission",
|
||||||
});
|
{
|
||||||
|
params: { namespace, object, relation, subject },
|
||||||
|
},
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -48,7 +51,8 @@ function PermissionChecker() {
|
|||||||
ReBAC 권한 검증 도구
|
ReBAC 권한 검증 도구
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory Keto를 통해 실시간으로 확인합니다.
|
특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory
|
||||||
|
Keto를 통해 실시간으로 확인합니다.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
@@ -56,74 +60,79 @@ function PermissionChecker() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Namespace</Label>
|
<Label>Namespace</Label>
|
||||||
<select
|
<select
|
||||||
value={namespace}
|
value={namespace}
|
||||||
onChange={e => setNamespace(e.target.value)}
|
onChange={(e) => setNamespace(e.target.value)}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<option value="Tenant">Tenant</option>
|
<option value="Tenant">Tenant</option>
|
||||||
<option value="TenantGroup">TenantGroup</option>
|
<option value="TenantGroup">TenantGroup</option>
|
||||||
<option value="RelyingParty">RelyingParty</option>
|
<option value="RelyingParty">RelyingParty</option>
|
||||||
<option value="System">System</option>
|
<option value="System">System</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Relation</Label>
|
<Label>Relation</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="view, manage, admins..."
|
placeholder="view, manage, admins..."
|
||||||
value={relation}
|
value={relation}
|
||||||
onChange={e => setRelation(e.target.value)}
|
onChange={(e) => setRelation(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Object ID</Label>
|
<Label>Object ID</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Tenant UUID 등"
|
placeholder="Tenant UUID 등"
|
||||||
value={object}
|
value={object}
|
||||||
onChange={e => setObject(e.target.value)}
|
onChange={(e) => setObject(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Subject (User:ID)</Label>
|
<Label>Subject (User:ID)</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="User:uuid 또는 Namespace:ID#Relation"
|
placeholder="User:uuid 또는 Namespace:ID#Relation"
|
||||||
value={subject}
|
value={subject}
|
||||||
onChange={e => setSubject(e.target.value)}
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => checkMutation.mutate()}
|
onClick={() => checkMutation.mutate()}
|
||||||
disabled={!object || !subject || checkMutation.isPending}
|
disabled={!object || !subject || checkMutation.isPending}
|
||||||
className="w-full md:w-auto px-12"
|
className="w-full md:w-auto px-12"
|
||||||
>
|
>
|
||||||
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
|
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{checkMutation.isSuccess && (
|
{checkMutation.isSuccess && (
|
||||||
<div className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
|
<div
|
||||||
result.allowed ? "bg-green-500/10 border-green-500/50 text-green-600" : "bg-destructive/10 border-destructive/50 text-destructive"
|
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
|
||||||
}`}>
|
result.allowed
|
||||||
{result.allowed ? (
|
? "bg-green-500/10 border-green-500/50 text-green-600"
|
||||||
<>
|
: "bg-destructive/10 border-destructive/50 text-destructive"
|
||||||
<CheckCircle2 size={48} />
|
}`}
|
||||||
<div className="text-xl font-bold">Access ALLOWED</div>
|
>
|
||||||
<p className="text-sm opacity-80 text-center">
|
{result.allowed ? (
|
||||||
해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속 포함)
|
<>
|
||||||
</p>
|
<CheckCircle2 size={48} />
|
||||||
</>
|
<div className="text-xl font-bold">Access ALLOWED</div>
|
||||||
) : (
|
<p className="text-sm opacity-80 text-center">
|
||||||
<>
|
해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속
|
||||||
<XCircle size={48} />
|
포함)
|
||||||
<div className="text-xl font-bold">Access DENIED</div>
|
</p>
|
||||||
<p className="text-sm opacity-80 text-center">
|
</>
|
||||||
해당 사용자는 요청한 리소스에 대해 권한이 없습니다.
|
) : (
|
||||||
</p>
|
<>
|
||||||
</>
|
<XCircle size={48} />
|
||||||
)}
|
<div className="text-xl font-bold">Access DENIED</div>
|
||||||
</div>
|
<p className="text-sm opacity-80 text-center">
|
||||||
|
해당 사용자는 요청한 리소스에 대해 권한이 없습니다.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Plus, Trash2, ShieldCheck, Search, UserPlus } from "lucide-react";
|
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useOutletContext } from "react-router-dom";
|
import { useOutletContext } from "react-router-dom";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../../components/ui/card";
|
} from "../../../components/ui/card";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -18,13 +19,12 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
import { Input } from "../../../components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
fetchGroupAdmins,
|
type TenantGroupSummary,
|
||||||
addGroupAdmin,
|
addGroupAdmin,
|
||||||
removeGroupAdmin,
|
fetchGroupAdmins,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
type TenantGroupSummary
|
removeGroupAdmin,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
|
|
||||||
function TenantGroupAdminsTab() {
|
function TenantGroupAdminsTab() {
|
||||||
@@ -98,21 +98,26 @@ function TenantGroupAdminsTab() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{adminsQuery.data?.length === 0 && (
|
{adminsQuery.data?.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
|
<TableCell
|
||||||
|
colSpan={3}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
등록된 관리자가 없습니다.
|
등록된 관리자가 없습니다.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{adminsQuery.data?.map((admin) => (
|
{adminsQuery.data?.map((admin) => (
|
||||||
<TableRow key={admin.id}>
|
<TableRow key={admin.id}>
|
||||||
<TableCell className="font-medium">{admin.name || "Unknown"}</TableCell>
|
<TableCell className="font-medium">
|
||||||
|
{admin.name || "Unknown"}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-xs">{admin.email}</TableCell>
|
<TableCell className="text-xs">{admin.email}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
|
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
|
||||||
disabled={removeMutation.isPending}
|
disabled={removeMutation.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} className="text-destructive" />
|
<Trash2 size={14} className="text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -129,8 +134,8 @@ function TenantGroupAdminsTab() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<UserPlus size={18} className="text-primary" />
|
<UserPlus size={18} className="text-primary" />
|
||||||
관리자 추가
|
관리자 추가
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -141,10 +146,10 @@ function TenantGroupAdminsTab() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="사용자 검색 (최소 2자)..."
|
placeholder="사용자 검색 (최소 2자)..."
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,36 +163,47 @@ function TenantGroupAdminsTab() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{searchTerm.length < 2 && (
|
{searchTerm.length < 2 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
|
<TableCell
|
||||||
|
colSpan={2}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
사용자 이름을 입력하여 검색하세요.
|
사용자 이름을 입력하여 검색하세요.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{searchTerm.length >= 2 && usersQuery.data?.items.length === 0 && (
|
{searchTerm.length >= 2 &&
|
||||||
<TableRow>
|
usersQuery.data?.items.length === 0 && (
|
||||||
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
|
<TableRow>
|
||||||
검색 결과가 없습니다.
|
<TableCell
|
||||||
</TableCell>
|
colSpan={2}
|
||||||
</TableRow>
|
className="text-center py-8 text-muted-foreground"
|
||||||
)}
|
>
|
||||||
{usersQuery.data?.items.filter(u => !adminsQuery.data?.some(a => a.id === u.id)).map((user) => (
|
검색 결과가 없습니다.
|
||||||
<TableRow key={user.id}>
|
</TableCell>
|
||||||
<TableCell>
|
</TableRow>
|
||||||
<div className="font-medium">{user.name}</div>
|
)}
|
||||||
<div className="text-[10px] text-muted-foreground">{user.email}</div>
|
{usersQuery.data?.items
|
||||||
</TableCell>
|
.filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
|
||||||
<TableCell className="text-right">
|
.map((user) => (
|
||||||
<Button
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{user.name}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleAddAdmin(user.id)}
|
onClick={() => handleAddAdmin(user.id)}
|
||||||
disabled={addMutation.isPending}
|
disabled={addMutation.isPending}
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ function TenantGroupCreatePage() {
|
|||||||
placeholder="baron-group"
|
placeholder="baron-group"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
URL이나 API에서 사용될 고유 식별자입니다. 비워두면 이름 기반으로 자동 생성됩니다.
|
URL이나 API에서 사용될 고유 식별자입니다. 비워두면 이름 기반으로
|
||||||
|
자동 생성됩니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -116,11 +117,12 @@ function TenantGroupCreatePage() {
|
|||||||
권한 상속 안내
|
권한 상속 안내
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
테넌트 그룹의 관리자는 소속된 모든 테넌트에 대한 관리 권한을 자동으로 가집니다.
|
테넌트 그룹의 관리자는 소속된 모든 테넌트에 대한 관리 권한을
|
||||||
|
자동으로 가집니다.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-sm text-[var(--color-muted)]">
|
<CardContent className="text-sm text-[var(--color-muted)]">
|
||||||
생성 후 상세 페이지에서 테넌트를 이 그룹에 할당할 수 있습니다.
|
생성 후 상세 페이지에서 테넌트를 이 그룹에 할당할 수 있습니다.
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ function TenantGroupDetailPage() {
|
|||||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
<Link to="/tenant-groups" className="inline-flex items-center gap-2 hover:text-foreground">
|
<Link
|
||||||
|
to="/tenant-groups"
|
||||||
|
className="inline-flex items-center gap-2 hover:text-foreground"
|
||||||
|
>
|
||||||
<ArrowLeft size={14} />
|
<ArrowLeft size={14} />
|
||||||
Groups
|
Groups
|
||||||
</Link>
|
</Link>
|
||||||
@@ -31,14 +34,15 @@ function TenantGroupDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-primary/10 rounded-lg">
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
<LayoutGrid size={24} className="text-primary" />
|
<LayoutGrid size={24} className="text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-semibold">
|
<h2 className="text-3xl font-semibold">
|
||||||
{groupQuery.data?.name ?? "Loading Group..."}
|
{groupQuery.data?.name ?? "Loading Group..."}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
{groupQuery.data?.description || "그룹 정보를 관리하고 소속 테넌트를 구성합니다."}
|
{groupQuery.data?.description ||
|
||||||
|
"그룹 정보를 관리하고 소속 테넌트를 구성합니다."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="muted">Super Admin only</Badge>
|
<Badge variant="muted">Super Admin only</Badge>
|
||||||
@@ -79,7 +83,9 @@ function TenantGroupDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Outlet context={{ group: groupQuery.data, refetch: groupQuery.refetch }} />
|
<Outlet
|
||||||
|
context={{ group: groupQuery.data, refetch: groupQuery.refetch }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import { Pencil, Plus, RefreshCw, Trash2, LayoutGrid } from "lucide-react";
|
import { LayoutGrid, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
@@ -85,8 +85,8 @@ function TenantGroupListPage() {
|
|||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<LayoutGrid size={20} className="text-primary" />
|
<LayoutGrid size={20} className="text-primary" />
|
||||||
Tenant Group Registry
|
Tenant Group Registry
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
총 {query.data?.total ?? 0}개 그룹
|
총 {query.data?.total ?? 0}개 그룹
|
||||||
@@ -130,7 +130,7 @@ function TenantGroupListPage() {
|
|||||||
<TableCell>{group.slug}</TableCell>
|
<TableCell>{group.slug}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{group.tenants?.length ?? 0}개
|
{group.tenants?.length ?? 0}개
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Plus, Trash2, Building2, Search } from "lucide-react";
|
import { Building2, Plus, Search, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useOutletContext } from "react-router-dom";
|
import { useOutletContext } from "react-router-dom";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../../components/ui/card";
|
} from "../../../components/ui/card";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -18,19 +20,17 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
import { Input } from "../../../components/ui/input";
|
|
||||||
import { Badge } from "../../../components/ui/badge";
|
|
||||||
import {
|
import {
|
||||||
addTenantToGroup,
|
type TenantGroupSummary,
|
||||||
removeTenantFromGroup,
|
addTenantToGroup,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
type TenantGroupSummary
|
removeTenantFromGroup,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
|
|
||||||
function TenantGroupTenantsTab() {
|
function TenantGroupTenantsTab() {
|
||||||
const { group, refetch } = useOutletContext<{
|
const { group, refetch } = useOutletContext<{
|
||||||
group: TenantGroupSummary;
|
group: TenantGroupSummary;
|
||||||
refetch: () => void
|
refetch: () => void;
|
||||||
}>();
|
}>();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@@ -67,13 +67,15 @@ function TenantGroupTenantsTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const availableTenants = tenantsQuery.data?.items.filter(
|
const availableTenants =
|
||||||
(t) => !group.tenants?.some((gt) => gt.id === t.id)
|
tenantsQuery.data?.items.filter(
|
||||||
) || [];
|
(t) => !group.tenants?.some((gt) => gt.id === t.id),
|
||||||
|
) || [];
|
||||||
|
|
||||||
const filteredAvailable = availableTenants.filter(
|
const filteredAvailable = availableTenants.filter(
|
||||||
(t) => t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
(t) =>
|
||||||
t.slug.toLowerCase().includes(searchTerm.toLowerCase())
|
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
t.slug.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,7 +103,10 @@ function TenantGroupTenantsTab() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{group.tenants?.length === 0 && (
|
{group.tenants?.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
|
<TableCell
|
||||||
|
colSpan={3}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
소속된 테넌트가 없습니다.
|
소속된 테넌트가 없습니다.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -112,10 +117,10 @@ function TenantGroupTenantsTab() {
|
|||||||
<TableCell className="text-xs">{t.slug}</TableCell>
|
<TableCell className="text-xs">{t.slug}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRemoveTenant(t.id)}
|
onClick={() => handleRemoveTenant(t.id)}
|
||||||
disabled={removeMutation.isPending}
|
disabled={removeMutation.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} className="text-destructive" />
|
<Trash2 size={14} className="text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -132,17 +137,17 @@ function TenantGroupTenantsTab() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Plus size={18} className="text-primary" />
|
<Plus size={18} className="text-primary" />
|
||||||
테넌트 추가
|
테넌트 추가
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="relative w-48">
|
<div className="relative w-48">
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="검색..."
|
placeholder="검색..."
|
||||||
className="pl-8 h-9"
|
className="pl-8 h-9"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -161,7 +166,10 @@ function TenantGroupTenantsTab() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredAvailable.length === 0 && (
|
{filteredAvailable.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
|
<TableCell
|
||||||
|
colSpan={3}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
추가할 수 있는 테넌트가 없습니다.
|
추가할 수 있는 테넌트가 없습니다.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -170,19 +178,21 @@ function TenantGroupTenantsTab() {
|
|||||||
<TableRow key={t.id}>
|
<TableRow key={t.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium">{t.name}</div>
|
<div className="font-medium">{t.name}</div>
|
||||||
<div className="text-[10px] text-muted-foreground">{t.slug}</div>
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
{t.slug}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge variant="outline" className="text-[10px]">
|
||||||
{t.status}
|
{t.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleAddTenant(t.id)}
|
onClick={() => handleAddTenant(t.id)}
|
||||||
disabled={addMutation.isPending}
|
disabled={addMutation.isPending}
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Plus, Trash2, ShieldCheck, Search, UserPlus } from "lucide-react";
|
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../../components/ui/card";
|
} from "../../../components/ui/card";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -18,12 +19,11 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
import { Input } from "../../../components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
fetchTenantAdmins,
|
addTenantAdmin,
|
||||||
addTenantAdmin,
|
fetchTenantAdmins,
|
||||||
removeTenantAdmin,
|
fetchUsers,
|
||||||
fetchUsers
|
removeTenantAdmin,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
|
|
||||||
function TenantAdminsTab() {
|
function TenantAdminsTab() {
|
||||||
@@ -97,21 +97,26 @@ function TenantAdminsTab() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{adminsQuery.data?.length === 0 && (
|
{adminsQuery.data?.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
|
<TableCell
|
||||||
|
colSpan={3}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
등록된 관리자가 없습니다.
|
등록된 관리자가 없습니다.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{adminsQuery.data?.map((admin) => (
|
{adminsQuery.data?.map((admin) => (
|
||||||
<TableRow key={admin.id}>
|
<TableRow key={admin.id}>
|
||||||
<TableCell className="font-medium">{admin.name || "Unknown"}</TableCell>
|
<TableCell className="font-medium">
|
||||||
|
{admin.name || "Unknown"}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-xs">{admin.email}</TableCell>
|
<TableCell className="text-xs">{admin.email}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
|
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
|
||||||
disabled={removeMutation.isPending}
|
disabled={removeMutation.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} className="text-destructive" />
|
<Trash2 size={14} className="text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -128,8 +133,8 @@ function TenantAdminsTab() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<UserPlus size={18} className="text-primary" />
|
<UserPlus size={18} className="text-primary" />
|
||||||
관리자 추가
|
관리자 추가
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -140,10 +145,10 @@ function TenantAdminsTab() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="사용자 검색 (최소 2자)..."
|
placeholder="사용자 검색 (최소 2자)..."
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -157,36 +162,47 @@ function TenantAdminsTab() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{searchTerm.length < 2 && (
|
{searchTerm.length < 2 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
|
<TableCell
|
||||||
|
colSpan={2}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
사용자 이름을 입력하여 검색하세요.
|
사용자 이름을 입력하여 검색하세요.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{searchTerm.length >= 2 && usersQuery.data?.items.length === 0 && (
|
{searchTerm.length >= 2 &&
|
||||||
<TableRow>
|
usersQuery.data?.items.length === 0 && (
|
||||||
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
|
<TableRow>
|
||||||
검색 결과가 없습니다.
|
<TableCell
|
||||||
</TableCell>
|
colSpan={2}
|
||||||
</TableRow>
|
className="text-center py-8 text-muted-foreground"
|
||||||
)}
|
>
|
||||||
{usersQuery.data?.items.filter(u => !adminsQuery.data?.some(a => a.id === u.id)).map((user) => (
|
검색 결과가 없습니다.
|
||||||
<TableRow key={user.id}>
|
</TableCell>
|
||||||
<TableCell>
|
</TableRow>
|
||||||
<div className="font-medium">{user.name}</div>
|
)}
|
||||||
<div className="text-[10px] text-muted-foreground">{user.email}</div>
|
{usersQuery.data?.items
|
||||||
</TableCell>
|
.filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
|
||||||
<TableCell className="text-right">
|
.map((user) => (
|
||||||
<Button
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{user.name}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleAddAdmin(user.id)}
|
onClick={() => handleAddAdmin(user.id)}
|
||||||
disabled={addMutation.isPending}
|
disabled={addMutation.isPending}
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -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"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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%
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,19 +68,19 @@ type SignupRequest struct {
|
|||||||
// User Profile Models
|
// User Profile Models
|
||||||
|
|
||||||
type UserProfileResponse struct {
|
type UserProfileResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Role string `json:"role"` // 추가
|
Role string `json:"role"` // 추가
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
AffiliationType string `json:"affiliationType"`
|
AffiliationType string `json:"affiliationType"`
|
||||||
CompanyCode string `json:"companyCode,omitempty"`
|
CompanyCode string `json:"companyCode,omitempty"`
|
||||||
TenantID *string `json:"tenantId,omitempty"` // 추가
|
TenantID *string `json:"tenantId,omitempty"` // 추가
|
||||||
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
Tenant *Tenant `json:"tenant,omitempty"`
|
Tenant *Tenant `json:"tenant,omitempty"`
|
||||||
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserRequest struct {
|
type UpdateUserRequest struct {
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ type Tenant struct {
|
|||||||
TenantGroup *TenantGroup `gorm:"foreignKey:TenantGroupID" json:"tenantGroup,omitempty"`
|
TenantGroup *TenantGroup `gorm:"foreignKey:TenantGroupID" json:"tenantGroup,omitempty"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `gorm:"default:'pending'" json:"status"`
|
Status string `gorm:"default:'pending'" json:"status"`
|
||||||
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
|
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
|
||||||
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
|
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tenant) IsActive() bool {
|
func (t *Tenant) IsActive() bool {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ func NewTenantGroupHandler(svc service.TenantGroupService, userSvc *service.Krat
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tenantGroupSummary struct {
|
type tenantGroupSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Tenants []tenantSummary `json:"tenants,omitempty"`
|
Tenants []tenantSummary `json:"tenants,omitempty"`
|
||||||
Config domain.JSONMap `json:"config,omitempty"`
|
Config domain.JSONMap `json:"config,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TenantGroupHandler) ListGroups(c *fiber.Ctx) error {
|
func (h *TenantGroupHandler) ListGroups(c *fiber.Ctx) error {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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%
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -15,14 +15,20 @@ void implSendLoginSuccess(String token) {
|
|||||||
final uri = Uri.base;
|
final uri = Uri.base;
|
||||||
|
|
||||||
// Try to find redirect_uri from standard parsing first, then manual string search
|
// Try to find redirect_uri from standard parsing first, then manual string search
|
||||||
String? redirectUri = uri.queryParameters['redirect_uri'] ?? uri.queryParameters['redirect_url'];
|
String? redirectUri =
|
||||||
|
uri.queryParameters['redirect_uri'] ??
|
||||||
|
uri.queryParameters['redirect_url'];
|
||||||
|
|
||||||
if (redirectUri == null) {
|
if (redirectUri == null) {
|
||||||
// Manual fallback for cases where Uri.base misses params
|
// Manual fallback for cases where Uri.base misses params
|
||||||
final searchParams = html.window.location.search;
|
final searchParams = html.window.location.search;
|
||||||
if (searchParams != null && searchParams.isNotEmpty) {
|
if (searchParams != null && searchParams.isNotEmpty) {
|
||||||
final sUri = Uri.parse('?${searchParams.startsWith('?') ? searchParams.substring(1) : searchParams}');
|
final sUri = Uri.parse(
|
||||||
redirectUri = sUri.queryParameters['redirect_uri'] ?? sUri.queryParameters['redirect_url'];
|
'?${searchParams.startsWith('?') ? searchParams.substring(1) : searchParams}',
|
||||||
|
);
|
||||||
|
redirectUri =
|
||||||
|
sUri.queryParameters['redirect_uri'] ??
|
||||||
|
sUri.queryParameters['redirect_url'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +75,7 @@ void implSendLoginSuccess(String token) {
|
|||||||
html.window.close();
|
html.window.close();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Should not happen given isPopup check, but as fallback:
|
// Should not happen given isPopup check, but as fallback:
|
||||||
debugPrint('No opener found during popup flow.');
|
debugPrint('No opener found during popup flow.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
|
|
||||||
Future<void> _fetchConsentInfo() async {
|
Future<void> _fetchConsentInfo() async {
|
||||||
try {
|
try {
|
||||||
final info = await AuthProxyService.getConsentInfo(widget.consentChallenge);
|
final info = await AuthProxyService.getConsentInfo(
|
||||||
|
widget.consentChallenge,
|
||||||
|
);
|
||||||
|
|
||||||
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
|
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
|
||||||
if (info['redirectTo'] != null) {
|
if (info['redirectTo'] != null) {
|
||||||
@@ -56,7 +58,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
details.forEach((scope, detail) {
|
details.forEach((scope, detail) {
|
||||||
if (detail is Map<String, dynamic>) {
|
if (detail is Map<String, dynamic>) {
|
||||||
// 설명 업데이트
|
// 설명 업데이트
|
||||||
if (detail['description'] != null && detail['description'].toString().isNotEmpty) {
|
if (detail['description'] != null &&
|
||||||
|
detail['description'].toString().isNotEmpty) {
|
||||||
_scopeDescriptions[scope] = detail['description'].toString();
|
_scopeDescriptions[scope] = detail['description'].toString();
|
||||||
}
|
}
|
||||||
// 필수 여부 업데이트
|
// 필수 여부 업데이트
|
||||||
@@ -76,7 +79,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택
|
// 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택
|
||||||
final requestedScopes = (info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
final requestedScopes =
|
||||||
|
(info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||||
_selectedScopes.addAll(requestedScopes);
|
_selectedScopes.addAll(requestedScopes);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -142,7 +146,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
setState(() => _isSubmitting = true);
|
setState(() => _isSubmitting = true);
|
||||||
try {
|
try {
|
||||||
final resp = await AuthProxyService.rejectConsent(widget.consentChallenge);
|
final resp = await AuthProxyService.rejectConsent(
|
||||||
|
widget.consentChallenge,
|
||||||
|
);
|
||||||
final redirectTo = resp['redirectTo'];
|
final redirectTo = resp['redirectTo'];
|
||||||
if (redirectTo != null) {
|
if (redirectTo != null) {
|
||||||
webWindow.redirectTo(redirectTo);
|
webWindow.redirectTo(redirectTo);
|
||||||
@@ -152,9 +158,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _isSubmitting = false);
|
setState(() => _isSubmitting = false);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,8 +175,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const CircularProgressIndicator()
|
? const CircularProgressIndicator()
|
||||||
: _error != null
|
: _error != null
|
||||||
? _buildErrorCard()
|
? _buildErrorCard()
|
||||||
: _buildConsentCard(context),
|
: _buildConsentCard(context),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -196,7 +202,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱';
|
final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱';
|
||||||
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
|
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
|
||||||
final clientLogo = _consentInfo?['client']?['logo_uri'];
|
final clientLogo = _consentInfo?['client']?['logo_uri'];
|
||||||
final requestedScopes = (_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
final requestedScopes =
|
||||||
|
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
|
||||||
|
[];
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -204,7 +212,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
margin: const EdgeInsets.all(16),
|
margin: const EdgeInsets.all(16),
|
||||||
child: Card(
|
child: Card(
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -235,7 +245,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (clientLogo != null && clientLogo.toString().isNotEmpty)
|
if (clientLogo != null &&
|
||||||
|
clientLogo.toString().isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 16),
|
padding: const EdgeInsets.only(right: 16),
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
@@ -286,7 +297,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'요청된 권한',
|
'요청된 권한',
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'총 ${requestedScopes.length}개',
|
'총 ${requestedScopes.length}개',
|
||||||
@@ -354,7 +368,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
)
|
)
|
||||||
: const Text(
|
: const Text(
|
||||||
'동의하고 계속하기',
|
'동의하고 계속하기',
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@@ -32,30 +32,36 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
final title = isProd
|
final title = isProd
|
||||||
? tr('msg.userfront.error.title', fallback: '인증 과정에서 오류가 발생했습니다')
|
? tr('msg.userfront.error.title', fallback: '인증 과정에서 오류가 발생했습니다')
|
||||||
: (hasCode
|
: (hasCode
|
||||||
? tr(
|
? tr(
|
||||||
'msg.userfront.error.title_with_code',
|
'msg.userfront.error.title_with_code',
|
||||||
fallback: '오류: {{code}}',
|
fallback: '오류: {{code}}',
|
||||||
params: {'code': normalizedCode},
|
params: {'code': normalizedCode},
|
||||||
)
|
)
|
||||||
: tr('msg.userfront.error.title_generic', fallback: '오류가 발생했습니다'));
|
: tr(
|
||||||
|
'msg.userfront.error.title_generic',
|
||||||
|
fallback: '오류가 발생했습니다',
|
||||||
|
));
|
||||||
final detail = isProd
|
final detail = isProd
|
||||||
? (isWhitelisted
|
? (isWhitelisted
|
||||||
? tr(
|
? tr(
|
||||||
'msg.userfront.error.whitelist.$normalizedCode',
|
'msg.userfront.error.whitelist.$normalizedCode',
|
||||||
fallback: whitelistFallback,
|
fallback: whitelistFallback,
|
||||||
)
|
)
|
||||||
: tr(
|
: tr(
|
||||||
'msg.userfront.error.detail_contact',
|
'msg.userfront.error.detail_contact',
|
||||||
fallback: '에러가 계속되면 관리자에게 문의해주세요',
|
fallback: '에러가 계속되면 관리자에게 문의해주세요',
|
||||||
))
|
))
|
||||||
: ((description?.isNotEmpty == true)
|
: ((description?.isNotEmpty == true)
|
||||||
? description!
|
? description!
|
||||||
: (hasCode
|
: (hasCode
|
||||||
? tr('msg.userfront.error.detail_generic', fallback: '오류가 발생했습니다.')
|
? tr(
|
||||||
: tr(
|
'msg.userfront.error.detail_generic',
|
||||||
'msg.userfront.error.detail_request',
|
fallback: '오류가 발생했습니다.',
|
||||||
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
|
)
|
||||||
)));
|
: tr(
|
||||||
|
'msg.userfront.error.detail_request',
|
||||||
|
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
|
||||||
|
)));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF7F8FA),
|
backgroundColor: const Color(0xFFF7F8FA),
|
||||||
@@ -124,20 +130,29 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF111827),
|
backgroundColor: const Color(0xFF111827),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
tr('ui.userfront.error.go_login', fallback: '로그인으로 이동'),
|
tr(
|
||||||
|
'ui.userfront.error.go_login',
|
||||||
|
fallback: '로그인으로 이동',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: () => context.go('/'),
|
onPressed: () => context.go('/'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: const Color(0xFF111827),
|
foregroundColor: const Color(0xFF111827),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
side: const BorderSide(color: Color(0xFFCBD5F5)),
|
side: const BorderSide(color: Color(0xFFCBD5F5)),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
|||||||
@@ -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: '재설정 링크 전송',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ class LoginScreen extends ConsumerStatefulWidget {
|
|||||||
final String? loginChallenge;
|
final String? loginChallenge;
|
||||||
final String? redirectUrl;
|
final String? redirectUrl;
|
||||||
|
|
||||||
const LoginScreen({super.key, this.verificationToken, this.loginChallenge, this.redirectUrl});
|
const LoginScreen({
|
||||||
|
super.key,
|
||||||
|
this.verificationToken,
|
||||||
|
this.loginChallenge,
|
||||||
|
this.redirectUrl,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||||
@@ -28,7 +33,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
final TextEditingController _linkIdController = TextEditingController();
|
final TextEditingController _linkIdController = TextEditingController();
|
||||||
final TextEditingController _passwordLoginIdController = TextEditingController();
|
final TextEditingController _passwordLoginIdController =
|
||||||
|
TextEditingController();
|
||||||
final TextEditingController _passwordController = TextEditingController();
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
String? _redirectUrl;
|
String? _redirectUrl;
|
||||||
String? _loginChallenge;
|
String? _loginChallenge;
|
||||||
@@ -41,8 +47,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
int _qrRemainingSeconds = 0;
|
int _qrRemainingSeconds = 0;
|
||||||
Timer? _qrCountdownTimer;
|
Timer? _qrCountdownTimer;
|
||||||
int _qrPollIntervalMs = 2000;
|
int _qrPollIntervalMs = 2000;
|
||||||
final TextEditingController _shortCodePrefixController = TextEditingController();
|
final TextEditingController _shortCodePrefixController =
|
||||||
final TextEditingController _shortCodeDigitsController = TextEditingController();
|
TextEditingController();
|
||||||
|
final TextEditingController _shortCodeDigitsController =
|
||||||
|
TextEditingController();
|
||||||
String? _linkPendingRef;
|
String? _linkPendingRef;
|
||||||
String? _lastLinkLoginId;
|
String? _lastLinkLoginId;
|
||||||
bool _lastLinkIsEmail = true;
|
bool _lastLinkIsEmail = true;
|
||||||
@@ -75,7 +83,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
||||||
_tabController.addListener(_handleTabSelection);
|
_tabController.addListener(_handleTabSelection);
|
||||||
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
|
_drySendEnabled =
|
||||||
|
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
||||||
|
!AuthProxyService.isProdEnv;
|
||||||
_redirectUrl = widget.redirectUrl;
|
_redirectUrl = widget.redirectUrl;
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
@@ -89,15 +99,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_loginChallenge = widget.loginChallenge ?? uri.queryParameters['login_challenge'];
|
_loginChallenge =
|
||||||
|
widget.loginChallenge ?? uri.queryParameters['login_challenge'];
|
||||||
final loginIdParam = uri.queryParameters['loginId'];
|
final loginIdParam = uri.queryParameters['loginId'];
|
||||||
final codeParam = uri.queryParameters['code'];
|
final codeParam = uri.queryParameters['code'];
|
||||||
final pendingRefParam = uri.queryParameters['pendingRef'];
|
final pendingRefParam = uri.queryParameters['pendingRef'];
|
||||||
final hasShortCodePath = uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
|
final hasShortCodePath =
|
||||||
|
uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
|
||||||
final hasTokenParam = uri.queryParameters.containsKey('t');
|
final hasTokenParam = uri.queryParameters.containsKey('t');
|
||||||
final hasVerificationToken = widget.verificationToken != null || hasTokenParam;
|
final hasVerificationToken =
|
||||||
|
widget.verificationToken != null || hasTokenParam;
|
||||||
final hasLoginCode = loginIdParam != null && codeParam != null;
|
final hasLoginCode = loginIdParam != null && codeParam != null;
|
||||||
_verificationOnly = hasVerificationToken || hasLoginCode || hasShortCodePath;
|
_verificationOnly =
|
||||||
|
hasVerificationToken || hasLoginCode || hasShortCodePath;
|
||||||
final notice = uri.queryParameters['notice'];
|
final notice = uri.queryParameters['notice'];
|
||||||
|
|
||||||
if (hasShortCodePath) {
|
if (hasShortCodePath) {
|
||||||
@@ -150,9 +164,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.cookie_check_failed',
|
'msg.userfront.login.cookie_check_failed',
|
||||||
fallback: '로그인 확인 실패: {{error}}',
|
fallback: '로그인 확인 실패: {{error}}',
|
||||||
params: {
|
params: {'error': e.toString().replaceFirst('Exception: ', '')},
|
||||||
'error': e.toString().replaceFirst('Exception: ', ''),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -171,8 +183,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
if (WebAuthIntegration.isPopup() || (_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
|
if (WebAuthIntegration.isPopup() ||
|
||||||
debugPrint("[Auth] Cookie session with external integration. Notifying...");
|
(_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
|
||||||
|
debugPrint(
|
||||||
|
"[Auth] Cookie session with external integration. Notifying...",
|
||||||
|
);
|
||||||
WebAuthIntegration.sendLoginSuccess(token);
|
WebAuthIntegration.sendLoginSuccess(token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -200,7 +215,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await AuthProxyService.checkCookieSession();
|
await AuthProxyService.checkCookieSession();
|
||||||
AuthTokenStore.setCookieMode(provider: AuthTokenStore.getProvider() ?? 'ory');
|
AuthTokenStore.setCookieMode(
|
||||||
|
provider: AuthTokenStore.getProvider() ?? 'ory',
|
||||||
|
);
|
||||||
await _acceptOidcLoginAndRedirect();
|
await _acceptOidcLoginAndRedirect();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] OIDC auto-accept cookie check failed: $e");
|
debugPrint("[Auth] OIDC auto-accept cookie check failed: $e");
|
||||||
@@ -289,7 +306,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final parts = jwt.split('.');
|
final parts = jwt.split('.');
|
||||||
if (parts.length != 3) return 'User';
|
if (parts.length != 3) return 'User';
|
||||||
|
|
||||||
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
|
final payload = utf8.decode(
|
||||||
|
base64Url.decode(base64Url.normalize(parts[1])),
|
||||||
|
);
|
||||||
final data = json.decode(payload);
|
final data = json.decode(payload);
|
||||||
return data['name'] ?? data['email'] ?? data['sub'] ?? 'User';
|
return data['name'] ?? data['email'] ?? data['sub'] ?? 'User';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -360,65 +379,65 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
void _startQrPolling() {
|
void _startQrPolling() {
|
||||||
_qrPollingTimer?.cancel();
|
_qrPollingTimer?.cancel();
|
||||||
_qrPollingTimer = Timer.periodic(Duration(milliseconds: _qrPollIntervalMs), (timer) async {
|
_qrPollingTimer = Timer.periodic(
|
||||||
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
|
Duration(milliseconds: _qrPollIntervalMs),
|
||||||
timer.cancel();
|
(timer) async {
|
||||||
return;
|
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
|
||||||
}
|
timer.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
|
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
|
||||||
if (res['error'] == 'slow_down') {
|
if (res['error'] == 'slow_down') {
|
||||||
final interval = res['interval'];
|
final interval = res['interval'];
|
||||||
if (interval is int && interval > 0) {
|
if (interval is int && interval > 0) {
|
||||||
final nextIntervalMs = interval * 1000;
|
final nextIntervalMs = interval * 1000;
|
||||||
if (nextIntervalMs != _qrPollIntervalMs) {
|
if (nextIntervalMs != _qrPollIntervalMs) {
|
||||||
_qrPollIntervalMs = nextIntervalMs;
|
_qrPollIntervalMs = nextIntervalMs;
|
||||||
|
timer.cancel();
|
||||||
|
_startQrPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_qrPollIntervalMs += 500;
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
_startQrPolling();
|
_startQrPolling();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
_qrPollIntervalMs += 500;
|
if (res['error'] == 'authorization_pending') {
|
||||||
timer.cancel();
|
|
||||||
_startQrPolling();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
if (res['error'] == 'expired_token') {
|
||||||
if (res['error'] == 'authorization_pending') {
|
timer.cancel();
|
||||||
return;
|
_qrCountdownTimer?.cancel();
|
||||||
}
|
|
||||||
if (res['error'] == 'expired_token') {
|
|
||||||
timer.cancel();
|
|
||||||
_qrCountdownTimer?.cancel();
|
|
||||||
_showError(
|
|
||||||
tr(
|
|
||||||
'msg.userfront.login.qr_expired',
|
|
||||||
fallback: 'QR 세션이 만료되었습니다.',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res['status'] == 'ok') {
|
|
||||||
timer.cancel();
|
|
||||||
_qrCountdownTimer?.cancel();
|
|
||||||
final token = res['sessionJwt'] ?? res['token'];
|
|
||||||
if (token is String && token.isNotEmpty) {
|
|
||||||
_completeLoginFromToken(token);
|
|
||||||
} else {
|
|
||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr('msg.userfront.login.qr_expired', fallback: 'QR 세션이 만료되었습니다.'),
|
||||||
'msg.userfront.login.token_missing',
|
|
||||||
fallback: '로그인 토큰을 확인할 수 없습니다.',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (res['status'] == 'ok') {
|
||||||
|
timer.cancel();
|
||||||
|
_qrCountdownTimer?.cancel();
|
||||||
|
final token = res['sessionJwt'] ?? res['token'];
|
||||||
|
if (token is String && token.isNotEmpty) {
|
||||||
|
_completeLoginFromToken(token);
|
||||||
|
} else {
|
||||||
|
_showError(
|
||||||
|
tr(
|
||||||
|
'msg.userfront.login.token_missing',
|
||||||
|
fallback: '로그인 토큰을 확인할 수 없습니다.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("[QR] Polling error: $e");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
},
|
||||||
debugPrint("[QR] Polling error: $e");
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _stopQrPolling() {
|
void _stopQrPolling() {
|
||||||
@@ -486,21 +505,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
Duration redirectDelay = const Duration(seconds: 2),
|
Duration redirectDelay = const Duration(seconds: 2),
|
||||||
}) {
|
}) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final resolvedTitle = title ??
|
final resolvedTitle =
|
||||||
tr(
|
title ?? tr('ui.userfront.login.verification.title', fallback: '승인 완료');
|
||||||
'ui.userfront.login.verification.title',
|
final resolvedPageTitle =
|
||||||
fallback: '승인 완료',
|
pageTitle ??
|
||||||
);
|
tr('ui.userfront.login.verification.page_title', fallback: '로그인 승인');
|
||||||
final resolvedPageTitle = pageTitle ??
|
final resolvedActionLabel =
|
||||||
tr(
|
actionLabel ??
|
||||||
'ui.userfront.login.verification.page_title',
|
tr('ui.userfront.login.verification.action_label', fallback: '확인');
|
||||||
fallback: '로그인 승인',
|
|
||||||
);
|
|
||||||
final resolvedActionLabel = actionLabel ??
|
|
||||||
tr(
|
|
||||||
'ui.userfront.login.verification.action_label',
|
|
||||||
fallback: '확인',
|
|
||||||
);
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_verificationApproved = true;
|
_verificationApproved = true;
|
||||||
_verificationMessage = message;
|
_verificationMessage = message;
|
||||||
@@ -524,11 +536,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.check_circle_outline, color: Colors.green, size: 72),
|
const Icon(
|
||||||
|
Icons.check_circle_outline,
|
||||||
|
color: Colors.green,
|
||||||
|
size: 72,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
_verificationTitle,
|
_verificationTitle,
|
||||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green),
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
@@ -544,7 +564,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final hasLocalSession = AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
|
final hasLocalSession =
|
||||||
|
AuthTokenStore.getToken() != null ||
|
||||||
|
AuthTokenStore.usesCookie();
|
||||||
final target = hasLocalSession ? '/' : '/signin';
|
final target = hasLocalSession ? '/' : '/signin';
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -586,10 +608,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
if (status == 'approved' || (jwt == null && _verificationOnly)) {
|
if (status == 'approved' || (jwt == null && _verificationOnly)) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -602,18 +621,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
|
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
|
||||||
@@ -629,9 +642,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _verifyLoginCode(String loginId, String code, {String? pendingRef}) async {
|
Future<void> _verifyLoginCode(
|
||||||
|
String loginId,
|
||||||
|
String code, {
|
||||||
|
String? pendingRef,
|
||||||
|
}) async {
|
||||||
final sanitizedLoginId = loginId.replaceAll(' ', '+');
|
final sanitizedLoginId = loginId.replaceAll(' ', '+');
|
||||||
debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId");
|
debugPrint(
|
||||||
|
"[Auth] Starting code verification for loginId: $sanitizedLoginId",
|
||||||
|
);
|
||||||
final approvedMessage = tr(
|
final approvedMessage = tr(
|
||||||
'msg.userfront.login.verification.approved',
|
'msg.userfront.login.verification.approved',
|
||||||
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
|
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
|
||||||
@@ -653,16 +672,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
final jwt = res['sessionJwt'] ?? res['token'];
|
final jwt = res['sessionJwt'] ?? res['token'];
|
||||||
final status = res['status']?.toString();
|
final status = res['status']?.toString();
|
||||||
debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId");
|
debugPrint(
|
||||||
|
"[Auth] Code verification successful for loginId: $sanitizedLoginId",
|
||||||
|
);
|
||||||
final hasLocalSession = await _hasValidLocalSession();
|
final hasLocalSession = await _hasValidLocalSession();
|
||||||
final actionPath = hasLocalSession ? '/' : '/signin';
|
final actionPath = hasLocalSession ? '/' : '/signin';
|
||||||
|
|
||||||
if (jwt == null && status == 'approved') {
|
if (jwt == null && status == 'approved') {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -676,18 +694,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_verificationOnly) {
|
if (_verificationOnly) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(
|
||||||
linkLoginMessage,
|
linkLoginMessage,
|
||||||
title: tr(
|
title: tr('ui.userfront.login.link.title', fallback: '링크 로그인 완료'),
|
||||||
'ui.userfront.login.link.title',
|
|
||||||
fallback: '링크 로그인 완료',
|
|
||||||
),
|
|
||||||
pageTitle: tr(
|
pageTitle: tr(
|
||||||
'ui.userfront.login.link.page_title',
|
'ui.userfront.login.link.page_title',
|
||||||
fallback: '링크 로그인',
|
fallback: '링크 로그인',
|
||||||
@@ -703,13 +715,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_verificationOnly && mounted) {
|
if (_verificationOnly && mounted) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
|
debugPrint(
|
||||||
|
"[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e",
|
||||||
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr(
|
||||||
@@ -747,10 +758,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
if (jwt == null && status == 'approved') {
|
if (jwt == null && status == 'approved') {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -764,10 +772,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_verificationOnly) {
|
if (_verificationOnly) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
||||||
@@ -775,10 +780,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_verificationOnly && mounted) {
|
if (_verificationOnly && mounted) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] Short code verification FAILED. Error: $e");
|
debugPrint("[Auth] Short code verification FAILED. Error: $e");
|
||||||
@@ -836,7 +838,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final res = await AuthProxyService.loginWithPassword(loginId, password, loginChallenge: _loginChallenge);
|
final res = await AuthProxyService.loginWithPassword(
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
loginChallenge: _loginChallenge,
|
||||||
|
);
|
||||||
final jwt = res['sessionJwt'];
|
final jwt = res['sessionJwt'];
|
||||||
final provider = res['provider'] as String?;
|
final provider = res['provider'] as String?;
|
||||||
final redirectTo = res['redirectTo'] as String?;
|
final redirectTo = res['redirectTo'] as String?;
|
||||||
@@ -860,9 +866,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.password.failed',
|
'msg.userfront.login.password.failed',
|
||||||
fallback: '로그인 실패: {{error}}',
|
fallback: '로그인 실패: {{error}}',
|
||||||
params: {
|
params: {'error': e.toString().replaceFirst('Exception: ', '')},
|
||||||
'error': e.toString().replaceFirst('Exception: ', ''),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -900,13 +904,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startEnchantedFlow(String loginId, {required bool isEmail, bool codeOnly = false}) async {
|
Future<void> _startEnchantedFlow(
|
||||||
|
String loginId, {
|
||||||
|
required bool isEmail,
|
||||||
|
bool codeOnly = false,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => const Center(child: CircularProgressIndicator()),
|
builder: (context) =>
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,7 +930,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final interval = initResponse['interval'];
|
final interval = initResponse['interval'];
|
||||||
final resendAfter = initResponse['resendAfter'];
|
final resendAfter = initResponse['resendAfter'];
|
||||||
final expiresIn = initResponse['expiresIn'];
|
final expiresIn = initResponse['expiresIn'];
|
||||||
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider");
|
debugPrint(
|
||||||
|
"[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider",
|
||||||
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -974,7 +985,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pollForSession(String pendingRef, {Duration? initialInterval}) async {
|
Future<void> _pollForSession(
|
||||||
|
String pendingRef, {
|
||||||
|
Duration? initialInterval,
|
||||||
|
}) async {
|
||||||
int attempts = 0;
|
int attempts = 0;
|
||||||
const maxAttempts = 60;
|
const maxAttempts = 60;
|
||||||
var pollInterval = initialInterval ?? const Duration(seconds: 2);
|
var pollInterval = initialInterval ?? const Duration(seconds: 2);
|
||||||
@@ -1047,10 +1061,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
|
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr('msg.userfront.login.link_timeout', fallback: '로그인 요청 시간이 초과되었습니다.'),
|
||||||
'msg.userfront.login.link_timeout',
|
|
||||||
fallback: '로그인 요청 시간이 초과되었습니다.',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1138,8 +1149,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (WebAuthIntegration.isPopup() || (_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
|
if (WebAuthIntegration.isPopup() ||
|
||||||
debugPrint("[Auth] External integration detected (popup or redirect). Notifying...");
|
(_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
|
||||||
|
debugPrint(
|
||||||
|
"[Auth] External integration detected (popup or redirect). Notifying...",
|
||||||
|
);
|
||||||
WebAuthIntegration.sendLoginSuccess(token);
|
WebAuthIntegration.sendLoginSuccess(token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1224,7 +1238,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (_drySendEnabled) ...[
|
if (_drySendEnabled) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFFF3CD),
|
color: const Color(0xFFFFF3CD),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -1232,13 +1249,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
|
const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: Color(0xFF8A6D3B),
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.dry_send',
|
'msg.userfront.login.dry_send',
|
||||||
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
|
fallback:
|
||||||
|
'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
|
||||||
),
|
),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Color(0xFF8A6D3B),
|
color: Color(0xFF8A6D3B),
|
||||||
@@ -1294,7 +1315,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
fallback: '이메일 또는 휴대폰 번호',
|
fallback: '이메일 또는 휴대폰 번호',
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
prefixIcon: const Icon(Icons.person_outline),
|
prefixIcon: const Icon(
|
||||||
|
Icons.person_outline,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _handlePasswordLogin(),
|
onSubmitted: (_) => _handlePasswordLogin(),
|
||||||
),
|
),
|
||||||
@@ -1308,7 +1331,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
fallback: '비밀번호',
|
fallback: '비밀번호',
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
prefixIcon: const Icon(Icons.lock_outline),
|
prefixIcon: const Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _handlePasswordLogin(),
|
onSubmitted: (_) => _handlePasswordLogin(),
|
||||||
),
|
),
|
||||||
@@ -1319,7 +1344,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
minimumSize: const Size.fromHeight(50),
|
minimumSize: const Size.fromHeight(50),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
tr('ui.userfront.login.action.submit', fallback: '로그인'),
|
tr(
|
||||||
|
'ui.userfront.login.action.submit',
|
||||||
|
fallback: '로그인',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1340,7 +1368,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
hintText: '',
|
hintText: '',
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
prefixIcon: const Icon(Icons.person_outline),
|
prefixIcon: const Icon(
|
||||||
|
Icons.person_outline,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _handleLinkLogin(),
|
onSubmitted: (_) => _handleLinkLogin(),
|
||||||
),
|
),
|
||||||
@@ -1363,7 +1393,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
'msg.userfront.login.link.helper',
|
'msg.userfront.login.link.helper',
|
||||||
fallback: '입력하신 정보로 로그인 링크를 전송합니다.',
|
fallback: '입력하신 정보로 로그인 링크를 전송합니다.',
|
||||||
),
|
),
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1371,9 +1404,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
Text(
|
Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.link.short_code_help',
|
'msg.userfront.login.link.short_code_help',
|
||||||
fallback: '링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.',
|
fallback:
|
||||||
|
'링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.',
|
||||||
|
),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -1382,16 +1419,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _shortCodePrefixController,
|
controller:
|
||||||
textCapitalization: TextCapitalization.characters,
|
_shortCodePrefixController,
|
||||||
|
textCapitalization:
|
||||||
|
TextCapitalization.characters,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.login.short_code.prefix',
|
'ui.userfront.login.short_code.prefix',
|
||||||
fallback: '영문 2자리',
|
fallback: '영문 2자리',
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border:
|
||||||
|
const OutlineInputBorder(),
|
||||||
hintText: 'AB',
|
hintText: 'AB',
|
||||||
hintStyle: const TextStyle(color: Colors.grey),
|
hintStyle: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
maxLength: 2,
|
maxLength: 2,
|
||||||
),
|
),
|
||||||
@@ -1400,22 +1442,28 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
Expanded(
|
Expanded(
|
||||||
flex: 4,
|
flex: 4,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _shortCodeDigitsController,
|
controller:
|
||||||
|
_shortCodeDigitsController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.login.short_code.digits',
|
'ui.userfront.login.short_code.digits',
|
||||||
fallback: '숫자 6자리',
|
fallback: '숫자 6자리',
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border:
|
||||||
|
const OutlineInputBorder(),
|
||||||
hintText: '345678',
|
hintText: '345678',
|
||||||
hintStyle: const TextStyle(color: Colors.grey),
|
hintStyle: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
suffixText: _linkExpireSeconds > 0
|
suffixText: _linkExpireSeconds > 0
|
||||||
? tr(
|
? tr(
|
||||||
'ui.userfront.login.short_code.expire_time',
|
'ui.userfront.login.short_code.expire_time',
|
||||||
fallback: '유효시간 {{time}}',
|
fallback: '유효시간 {{time}}',
|
||||||
params: {
|
params: {
|
||||||
'time': _formatTime(_linkExpireSeconds),
|
'time': _formatTime(
|
||||||
|
_linkExpireSeconds,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -1428,13 +1476,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final prefix = _shortCodePrefixController.text.trim().toUpperCase();
|
final prefix =
|
||||||
final digits = _shortCodeDigitsController.text.trim();
|
_shortCodePrefixController.text
|
||||||
if (prefix.length != 2 || digits.length != 6) {
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
|
final digits =
|
||||||
|
_shortCodeDigitsController.text
|
||||||
|
.trim();
|
||||||
|
if (prefix.length != 2 ||
|
||||||
|
digits.length != 6) {
|
||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.short_code.invalid',
|
'msg.userfront.login.short_code.invalid',
|
||||||
fallback: '문자 2개와 숫자 6자리를 입력해 주세요.',
|
fallback:
|
||||||
|
'문자 2개와 숫자 6자리를 입력해 주세요.',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -1458,27 +1513,35 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_showInfo(
|
_showInfo(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.link.resend_wait',
|
'msg.userfront.login.link.resend_wait',
|
||||||
fallback: '재발송은 {{time}} 후 가능합니다.',
|
fallback:
|
||||||
|
'재발송은 {{time}} 후 가능합니다.',
|
||||||
params: {
|
params: {
|
||||||
'time': _formatTime(_linkResendSeconds),
|
'time': _formatTime(
|
||||||
|
_linkResendSeconds,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
|
final loginId =
|
||||||
|
_lastLinkLoginId ??
|
||||||
|
_linkIdController.text.trim();
|
||||||
if (loginId.isEmpty) {
|
if (loginId.isEmpty) {
|
||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.link.missing_login_id',
|
'msg.userfront.login.link.missing_login_id',
|
||||||
fallback: '이메일 또는 휴대폰 번호를 입력해 주세요.',
|
fallback:
|
||||||
|
'이메일 또는 휴대폰 번호를 입력해 주세요.',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_startEnchantedFlow(
|
_startEnchantedFlow(
|
||||||
loginId,
|
loginId,
|
||||||
isEmail: _lastLinkIsEmail || loginId.contains('@'),
|
isEmail:
|
||||||
|
_lastLinkIsEmail ||
|
||||||
|
loginId.contains('@'),
|
||||||
codeOnly: false,
|
codeOnly: false,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1488,7 +1551,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
'ui.userfront.login.link.resend_with_time',
|
'ui.userfront.login.link.resend_with_time',
|
||||||
fallback: '재발송 ({{time}})',
|
fallback: '재발송 ({{time}})',
|
||||||
params: {
|
params: {
|
||||||
'time': _formatTime(_linkResendSeconds),
|
'time': _formatTime(
|
||||||
|
_linkResendSeconds,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: tr(
|
: tr(
|
||||||
@@ -1505,15 +1570,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_showInfo(
|
_showInfo(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.link.resend_wait',
|
'msg.userfront.login.link.resend_wait',
|
||||||
fallback: '재발송은 {{time}} 후 가능합니다.',
|
fallback:
|
||||||
|
'재발송은 {{time}} 후 가능합니다.',
|
||||||
params: {
|
params: {
|
||||||
'time': _formatTime(_linkResendSeconds),
|
'time': _formatTime(
|
||||||
|
_linkResendSeconds,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
|
final loginId =
|
||||||
|
_lastLinkLoginId ??
|
||||||
|
_linkIdController.text.trim();
|
||||||
if (loginId.isEmpty) {
|
if (loginId.isEmpty) {
|
||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr(
|
||||||
@@ -1534,7 +1604,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
'ui.userfront.login.link.code_only',
|
'ui.userfront.login.link.code_only',
|
||||||
fallback: '코드만 받기({{time}})',
|
fallback: '코드만 받기({{time}})',
|
||||||
params: {
|
params: {
|
||||||
'time': _formatTime(_linkResendSeconds),
|
'time': _formatTime(
|
||||||
|
_linkResendSeconds,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1553,13 +1625,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
const CircularProgressIndicator()
|
const CircularProgressIndicator()
|
||||||
else if (_qrImageBase64 != null)
|
else if (_qrImageBase64 != null)
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
border: Border.all(
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: QrImageView(
|
child: QrImageView(
|
||||||
data: _qrImageBase64!,
|
data: _qrImageBase64!,
|
||||||
@@ -1570,20 +1647,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
_qrRemainingSeconds > 0
|
_qrRemainingSeconds > 0
|
||||||
? tr(
|
? tr(
|
||||||
'ui.userfront.login.qr.remaining',
|
'ui.userfront.login.qr.remaining',
|
||||||
fallback: '남은 시간: {{time}}',
|
fallback: '남은 시간: {{time}}',
|
||||||
params: {
|
params: {
|
||||||
'time': _formatTime(_qrRemainingSeconds),
|
'time': _formatTime(
|
||||||
},
|
_qrRemainingSeconds,
|
||||||
)
|
),
|
||||||
: tr(
|
},
|
||||||
'ui.userfront.login.qr.expired',
|
)
|
||||||
fallback: 'QR 코드 만료됨',
|
: tr(
|
||||||
),
|
'ui.userfront.login.qr.expired',
|
||||||
|
fallback: 'QR 코드 만료됨',
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red,
|
color: _qrRemainingSeconds > 30
|
||||||
|
? Colors.blue
|
||||||
|
: Colors.red,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1594,7 +1675,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
fallback: '모바일 앱으로 스캔하세요',
|
fallback: '모바일 앱으로 스캔하세요',
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _startQrFlow,
|
onPressed: _startQrFlow,
|
||||||
@@ -1640,7 +1724,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
'msg.userfront.login.no_account',
|
'msg.userfront.login.no_account',
|
||||||
fallback: '계정이 없으신가요?',
|
fallback: '계정이 없으신가요?',
|
||||||
),
|
),
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 14),
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.push('/signup'),
|
onPressed: () => context.push('/signup'),
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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: '카메라 권한 요청하기',
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ class ResetPasswordScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||||
final TextEditingController _passwordController = TextEditingController();
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
final TextEditingController _confirmPasswordController = TextEditingController();
|
final TextEditingController _confirmPasswordController =
|
||||||
|
TextEditingController();
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _loginId;
|
String? _loginId;
|
||||||
@@ -31,13 +32,13 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
|
|
||||||
// 2. Fallback to URI query parameter if not available via router
|
// 2. Fallback to URI query parameter if not available via router
|
||||||
if (_loginId == null || _loginId!.isEmpty) {
|
if (_loginId == null || _loginId!.isEmpty) {
|
||||||
final uri = Uri.base;
|
final uri = Uri.base;
|
||||||
_loginId = uri.queryParameters['loginId'];
|
_loginId = uri.queryParameters['loginId'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 토큰도 함께 읽어놓는다.
|
// 토큰도 함께 읽어놓는다.
|
||||||
final uri = Uri.base;
|
final uri = Uri.base;
|
||||||
_token = uri.queryParameters['token'];
|
_token = uri.queryParameters['token'];
|
||||||
|
|
||||||
_loadPolicy();
|
_loadPolicy();
|
||||||
}
|
}
|
||||||
@@ -66,7 +67,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
|
|
||||||
Future<void> _handlePasswordReset() async {
|
Future<void> _handlePasswordReset() async {
|
||||||
if (_formKey.currentState?.validate() != true) return;
|
if (_formKey.currentState?.validate() != true) return;
|
||||||
if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) {
|
if ((_loginId == null || _loginId!.isEmpty) &&
|
||||||
|
(_token == null || _token!.isEmpty)) {
|
||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.reset.invalid_link',
|
'msg.userfront.reset.invalid_link',
|
||||||
@@ -163,9 +165,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (requiresNumber) {
|
if (requiresNumber) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'));
|
||||||
tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (requiresSymbol) {
|
if (requiresSymbol) {
|
||||||
parts.add(
|
parts.add(
|
||||||
@@ -180,16 +180,16 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정')),
|
||||||
tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정'),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 400),
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: (_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)
|
child:
|
||||||
|
(_loginId == null || _loginId!.isEmpty) &&
|
||||||
|
(_token == null || _token!.isEmpty)
|
||||||
? _buildInvalidTokenView()
|
? _buildInvalidTokenView()
|
||||||
: Form(
|
: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
@@ -227,7 +227,9 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
prefixIcon: const Icon(Icons.lock_outline),
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_isPasswordObscured ? Icons.visibility_off : Icons.visibility,
|
_isPasswordObscured
|
||||||
|
? Icons.visibility_off
|
||||||
|
: Icons.visibility,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -244,7 +246,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
fallback: '비밀번호를 입력해주세요.',
|
fallback: '비밀번호를 입력해주세요.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
final minLength =
|
||||||
|
(_policy?['minLength'] as int?) ?? 12;
|
||||||
if (val.length < minLength) {
|
if (val.length < minLength) {
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.reset.error.min_length',
|
'msg.userfront.reset.error.min_length',
|
||||||
@@ -262,7 +265,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
if (hasNumber) typeCount++;
|
if (hasNumber) typeCount++;
|
||||||
if (hasSymbol) typeCount++;
|
if (hasSymbol) typeCount++;
|
||||||
|
|
||||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
final minTypes =
|
||||||
|
(_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||||
if (minTypes > 0 && typeCount < minTypes) {
|
if (minTypes > 0 && typeCount < minTypes) {
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.reset.error.min_types',
|
'msg.userfront.reset.error.min_types',
|
||||||
@@ -290,7 +294,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
fallback: '최소 1개 이상의 숫자를 포함해야 합니다.',
|
fallback: '최소 1개 이상의 숫자를 포함해야 합니다.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
|
if ((_policy?['nonAlphanumeric'] ?? true) &&
|
||||||
|
!hasSymbol) {
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.reset.error.symbol',
|
'msg.userfront.reset.error.symbol',
|
||||||
fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.',
|
fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.',
|
||||||
@@ -312,11 +317,14 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
prefixIcon: const Icon(Icons.lock_outline),
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_isConfirmPasswordObscured ? Icons.visibility_off : Icons.visibility,
|
_isConfirmPasswordObscured
|
||||||
|
? Icons.visibility_off
|
||||||
|
: Icons.visibility,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isConfirmPasswordObscured = !_isConfirmPasswordObscured;
|
_isConfirmPasswordObscured =
|
||||||
|
!_isConfirmPasswordObscured;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -369,8 +377,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
const Icon(Icons.error_outline, color: Colors.red, size: 60),
|
const Icon(Icons.error_outline, color: Colors.red, size: 60),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.reset.invalid_title',
|
tr('msg.userfront.reset.invalid_title', fallback: '유효하지 않은 링크입니다.'),
|
||||||
fallback: '유효하지 않은 링크입니다.'),
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -164,30 +164,39 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
final email = _emailController.text.trim();
|
final email = _emailController.text.trim();
|
||||||
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
||||||
if (!emailRegex.hasMatch(email)) {
|
if (!emailRegex.hasMatch(email)) {
|
||||||
setState(() => _emailError = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.invalid',
|
() => _emailError = tr(
|
||||||
fallback: '유효한 이메일 형식이 아닙니다.',
|
'msg.userfront.signup.email.invalid',
|
||||||
));
|
fallback: '유효한 이메일 형식이 아닙니다.',
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() { _isLoading = true; _emailError = null; });
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_emailError = null;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
final available = await AuthProxyService.checkEmailAvailability(email);
|
final available = await AuthProxyService.checkEmailAvailability(email);
|
||||||
if (!available) {
|
if (!available) {
|
||||||
setState(() => _emailError = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.duplicate',
|
() => _emailError = tr(
|
||||||
fallback: '이미 가입된 이메일입니다.',
|
'msg.userfront.signup.email.duplicate',
|
||||||
));
|
fallback: '이미 가입된 이메일입니다.',
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await AuthProxyService.sendSignupCode(email, 'email');
|
await AuthProxyService.sendSignupCode(email, 'email');
|
||||||
_startTimer('email');
|
_startTimer('email');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _emailError = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.send_failed',
|
() => _emailError = tr(
|
||||||
fallback: '발송 실패: {{error}}',
|
'msg.userfront.signup.email.send_failed',
|
||||||
params: {'error': e.toString()},
|
fallback: '발송 실패: {{error}}',
|
||||||
));
|
params: {'error': e.toString()},
|
||||||
|
),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -197,7 +206,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
final code = _emailCodeController.text.trim();
|
final code = _emailCodeController.text.trim();
|
||||||
if (code.length != 6) return;
|
if (code.length != 6) return;
|
||||||
try {
|
try {
|
||||||
final success = await AuthProxyService.verifySignupCode(_emailController.text.trim(), 'email', code);
|
final success = await AuthProxyService.verifySignupCode(
|
||||||
|
_emailController.text.trim(),
|
||||||
|
'email',
|
||||||
|
code,
|
||||||
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isEmailVerified = true;
|
_isEmailVerified = true;
|
||||||
@@ -206,33 +219,42 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
_emailError = null;
|
_emailError = null;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(() => _emailError = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.code_mismatch',
|
() => _emailError = tr(
|
||||||
fallback: '인증코드가 일치하지 않습니다.',
|
'msg.userfront.signup.email.code_mismatch',
|
||||||
));
|
fallback: '인증코드가 일치하지 않습니다.',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _emailError = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.verify_failed',
|
() => _emailError = tr(
|
||||||
fallback: '인증 실패: {{error}}',
|
'msg.userfront.signup.email.verify_failed',
|
||||||
params: {'error': e.toString()},
|
fallback: '인증 실패: {{error}}',
|
||||||
));
|
params: {'error': e.toString()},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendPhoneCode() async {
|
Future<void> _sendPhoneCode() async {
|
||||||
final phone = _phoneController.text.trim();
|
final phone = _phoneController.text.trim();
|
||||||
if (phone.isEmpty) return;
|
if (phone.isEmpty) return;
|
||||||
setState(() { _isLoading = true; _phoneError = null; });
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_phoneError = null;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await AuthProxyService.sendSignupCode(phone, 'phone');
|
await AuthProxyService.sendSignupCode(phone, 'phone');
|
||||||
_startTimer('phone');
|
_startTimer('phone');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _phoneError = tr(
|
setState(
|
||||||
'msg.userfront.signup.phone.send_failed',
|
() => _phoneError = tr(
|
||||||
fallback: '발송 실패: {{error}}',
|
'msg.userfront.signup.phone.send_failed',
|
||||||
params: {'error': e.toString()},
|
fallback: '발송 실패: {{error}}',
|
||||||
));
|
params: {'error': e.toString()},
|
||||||
|
),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -242,7 +264,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
final code = _phoneCodeController.text.trim();
|
final code = _phoneCodeController.text.trim();
|
||||||
if (code.length != 6) return;
|
if (code.length != 6) return;
|
||||||
try {
|
try {
|
||||||
final success = await AuthProxyService.verifySignupCode(_phoneController.text.trim(), 'phone', code);
|
final success = await AuthProxyService.verifySignupCode(
|
||||||
|
_phoneController.text.trim(),
|
||||||
|
'phone',
|
||||||
|
code,
|
||||||
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isPhoneVerified = true;
|
_isPhoneVerified = true;
|
||||||
@@ -251,26 +277,32 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
_phoneError = null;
|
_phoneError = null;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(() => _phoneError = tr(
|
setState(
|
||||||
'msg.userfront.signup.phone.code_mismatch',
|
() => _phoneError = tr(
|
||||||
fallback: '인증코드가 일치하지 않습니다.',
|
'msg.userfront.signup.phone.code_mismatch',
|
||||||
));
|
fallback: '인증코드가 일치하지 않습니다.',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _phoneError = tr(
|
setState(
|
||||||
'msg.userfront.signup.phone.verify_failed',
|
() => _phoneError = tr(
|
||||||
fallback: '인증 실패: {{error}}',
|
'msg.userfront.signup.phone.verify_failed',
|
||||||
params: {'error': e.toString()},
|
fallback: '인증 실패: {{error}}',
|
||||||
));
|
params: {'error': e.toString()},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleSignup() async {
|
Future<void> _handleSignup() async {
|
||||||
if (_passwordController.text != _confirmPasswordController.text) {
|
if (_passwordController.text != _confirmPasswordController.text) {
|
||||||
setState(() => _confirmPasswordError = tr(
|
setState(
|
||||||
'msg.userfront.signup.password.mismatch',
|
() => _confirmPasswordError = tr(
|
||||||
fallback: '비밀번호가 일치하지 않습니다.',
|
'msg.userfront.signup.password.mismatch',
|
||||||
));
|
fallback: '비밀번호가 일치하지 않습니다.',
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
@@ -288,7 +320,9 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
phone: _phoneController.text.trim(),
|
phone: _phoneController.text.trim(),
|
||||||
affiliationType: _affiliationType,
|
affiliationType: _affiliationType,
|
||||||
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
|
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
|
||||||
department: _deptController.text.trim().isEmpty ? (_affiliationType == 'GENERAL' ? 'External' : '') : _deptController.text.trim(),
|
department: _deptController.text.trim().isEmpty
|
||||||
|
? (_affiliationType == 'GENERAL' ? 'External' : '')
|
||||||
|
: _deptController.text.trim(),
|
||||||
termsAccepted: true,
|
termsAccepted: true,
|
||||||
);
|
);
|
||||||
if (mounted) _showSuccessDialog();
|
if (mounted) _showSuccessDialog();
|
||||||
@@ -394,11 +428,28 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 12,
|
radius: 12,
|
||||||
backgroundColor: isDone ? Colors.green : (isCurrent ? Colors.black : Colors.grey[300]),
|
backgroundColor: isDone
|
||||||
child: isDone ? const Icon(Icons.check, size: 14, color: Colors.white) : Text('$step', style: TextStyle(color: isCurrent ? Colors.white : Colors.black54, fontSize: 10)),
|
? Colors.green
|
||||||
|
: (isCurrent ? Colors.black : Colors.grey[300]),
|
||||||
|
child: isDone
|
||||||
|
? const Icon(Icons.check, size: 14, color: Colors.white)
|
||||||
|
: Text(
|
||||||
|
'$step',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isCurrent ? Colors.white : Colors.black54,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(label, style: TextStyle(fontSize: 9, color: isCurrent ? Colors.black : Colors.grey, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal)),
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
color: isCurrent ? Colors.black : Colors.grey,
|
||||||
|
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -438,10 +489,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
),
|
),
|
||||||
child: CheckboxListTile(
|
child: CheckboxListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
tr(
|
tr('ui.userfront.signup.agreement.all', fallback: '모두 동의합니다'),
|
||||||
'ui.userfront.signup.agreement.all',
|
|
||||||
fallback: '모두 동의합니다',
|
|
||||||
),
|
|
||||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
value: _termsAccepted && _privacyAccepted,
|
value: _termsAccepted && _privacyAccepted,
|
||||||
@@ -488,8 +536,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
title: Text(title,
|
title: Text(
|
||||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
title,
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
@@ -508,7 +558,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Text(
|
child: Text(
|
||||||
content,
|
content,
|
||||||
style: const TextStyle(fontSize: 12, color: Colors.grey, height: 1.5),
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -517,8 +571,8 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String get _tosText => tr(
|
static String get _tosText => tr(
|
||||||
'msg.userfront.signup.tos_full',
|
'msg.userfront.signup.tos_full',
|
||||||
fallback: """
|
fallback: """
|
||||||
바론 소프트웨어 이용약관
|
바론 소프트웨어 이용약관
|
||||||
|
|
||||||
제1장 총칙
|
제1장 총칙
|
||||||
@@ -589,11 +643,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
부칙
|
부칙
|
||||||
본 약관은 2024년 10월 1일부터 시행됩니다.
|
본 약관은 2024년 10월 1일부터 시행됩니다.
|
||||||
""",
|
""",
|
||||||
);
|
);
|
||||||
|
|
||||||
static String get _privacyText => tr(
|
static String get _privacyText => tr(
|
||||||
'msg.userfront.signup.privacy_full',
|
'msg.userfront.signup.privacy_full',
|
||||||
fallback: """
|
fallback: """
|
||||||
개인정보 수집 및 이용 동의
|
개인정보 수집 및 이용 동의
|
||||||
|
|
||||||
바론서비스 개인정보처리방침
|
바론서비스 개인정보처리방침
|
||||||
@@ -702,7 +756,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
제8조 (기타)
|
제8조 (기타)
|
||||||
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
|
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
|
||||||
""",
|
""",
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildStepAuth() {
|
Widget _buildStepAuth() {
|
||||||
return Column(
|
return Column(
|
||||||
@@ -719,7 +773,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
// 가족사 이메일 안내 문구
|
// 가족사 이메일 안내 문구
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(6)),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue[50],
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.info_outline, size: 16, color: Colors.blue),
|
const Icon(Icons.info_outline, size: 16, color: Colors.blue),
|
||||||
@@ -730,7 +787,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
'msg.userfront.signup.auth.affiliate_notice',
|
'msg.userfront.signup.auth.affiliate_notice',
|
||||||
fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
|
fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
|
||||||
),
|
),
|
||||||
style: const TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500),
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.blue,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -764,8 +825,14 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
height: 55,
|
height: 55,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode,
|
onPressed: (_isEmailVerified || _isLoading)
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
|
? null
|
||||||
|
: _sendEmailCode,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[100],
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_emailSeconds > 0
|
_emailSeconds > 0
|
||||||
? tr('ui.common.resend', fallback: '재발송')
|
? tr('ui.common.resend', fallback: '재발송')
|
||||||
@@ -791,8 +858,13 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
|
inputFormatters: [
|
||||||
onChanged: (val) { if(val.length == 6) _verifyEmailCode(); },
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(6),
|
||||||
|
],
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val.length == 6) _verifyEmailCode();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (_isEmailVerified)
|
if (_isEmailVerified)
|
||||||
@@ -837,8 +909,14 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
height: 55,
|
height: 55,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode,
|
onPressed: (_isPhoneVerified || _isLoading)
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
|
? null
|
||||||
|
: _sendPhoneCode,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[100],
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_phoneSeconds > 0
|
_phoneSeconds > 0
|
||||||
? tr('ui.common.resend', fallback: '재발송')
|
? tr('ui.common.resend', fallback: '재발송')
|
||||||
@@ -864,8 +942,13 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
|
inputFormatters: [
|
||||||
onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); },
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(6),
|
||||||
|
],
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val.length == 6) _verifyPhoneCode();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (_isPhoneVerified)
|
if (_isPhoneVerified)
|
||||||
@@ -903,10 +986,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
onChanged: (_) => setState(() {}),
|
onChanged: (_) => setState(() {}),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr('ui.userfront.signup.profile.name', fallback: '이름'),
|
||||||
'ui.userfront.signup.profile.name',
|
|
||||||
fallback: '이름',
|
|
||||||
),
|
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -936,19 +1016,13 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'GENERAL',
|
value: 'GENERAL',
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr('domain.affiliation.general', fallback: '일반 사용자'),
|
||||||
'domain.affiliation.general',
|
|
||||||
fallback: '일반 사용자',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'AFFILIATE',
|
value: 'AFFILIATE',
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr('domain.affiliation.affiliate', fallback: '가족사 임직원'),
|
||||||
'domain.affiliation.affiliate',
|
|
||||||
fallback: '가족사 임직원',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -985,39 +1059,27 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
items: [
|
items: [
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'HANMAC',
|
value: 'HANMAC',
|
||||||
child: Text(
|
child: Text(tr('domain.company.hanmac', fallback: '한맥')),
|
||||||
tr('domain.company.hanmac', fallback: '한맥'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'SAMAN',
|
value: 'SAMAN',
|
||||||
child: Text(
|
child: Text(tr('domain.company.saman', fallback: '삼안')),
|
||||||
tr('domain.company.saman', fallback: '삼안'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'PTC',
|
value: 'PTC',
|
||||||
child: Text(
|
child: Text(tr('domain.company.ptc', fallback: 'PTC')),
|
||||||
tr('domain.company.ptc', fallback: 'PTC'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'JANGHEON',
|
value: 'JANGHEON',
|
||||||
child: Text(
|
child: Text(tr('domain.company.jangheon', fallback: '장헌')),
|
||||||
tr('domain.company.jangheon', fallback: '장헌'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'BARON',
|
value: 'BARON',
|
||||||
child: Text(
|
child: Text(tr('domain.company.baron', fallback: '바론')),
|
||||||
tr('domain.company.baron', fallback: '바론'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'HALLA',
|
value: 'HALLA',
|
||||||
child: Text(
|
child: Text(tr('domain.company.halla', fallback: '한라')),
|
||||||
tr('domain.company.halla', fallback: '한라'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: _isAffiliateEmail
|
onChanged: _isAffiliateEmail
|
||||||
@@ -1038,7 +1100,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
'ui.userfront.signup.profile.department_optional',
|
'ui.userfront.signup.profile.department_optional',
|
||||||
fallback: '소속 정보 (선택)',
|
fallback: '소속 정보 (선택)',
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder()
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1076,36 +1138,16 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (requiresUpper) {
|
if (requiresUpper) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.signup.policy.uppercase', fallback: '대문자'));
|
||||||
tr(
|
|
||||||
'msg.userfront.signup.policy.uppercase',
|
|
||||||
fallback: '대문자',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (requiresLower) {
|
if (requiresLower) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.signup.policy.lowercase', fallback: '소문자'));
|
||||||
tr(
|
|
||||||
'msg.userfront.signup.policy.lowercase',
|
|
||||||
fallback: '소문자',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (requiresNumber) {
|
if (requiresNumber) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.signup.policy.number', fallback: '숫자'));
|
||||||
tr(
|
|
||||||
'msg.userfront.signup.policy.number',
|
|
||||||
fallback: '숫자',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (requiresSymbol) {
|
if (requiresSymbol) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.signup.policy.symbol', fallback: '특수문자'));
|
||||||
tr(
|
|
||||||
'msg.userfront.signup.policy.symbol',
|
|
||||||
fallback: '특수문자',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tr(
|
return tr(
|
||||||
@@ -1152,7 +1194,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
// 비밀번호 정책 안내 박스
|
// 비밀번호 정책 안내 박스
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(8)),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue[50],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.security, size: 18, color: Colors.blue),
|
const Icon(Icons.security, size: 18, color: Colors.blue),
|
||||||
@@ -1160,7 +1205,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_buildPolicyDescription(),
|
_buildPolicyDescription(),
|
||||||
style: TextStyle(fontSize: 12, color: Colors.blue[800], fontWeight: FontWeight.w500),
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.blue[800],
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1219,10 +1268,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
),
|
),
|
||||||
if (requiresNumber)
|
if (requiresNumber)
|
||||||
_cryptoCheck(
|
_cryptoCheck(
|
||||||
tr(
|
tr('msg.userfront.signup.password.rule.number', fallback: '숫자'),
|
||||||
'msg.userfront.signup.password.rule.number',
|
|
||||||
fallback: '숫자',
|
|
||||||
),
|
|
||||||
hasDigit,
|
hasDigit,
|
||||||
),
|
),
|
||||||
if (requiresSymbol)
|
if (requiresSymbol)
|
||||||
@@ -1266,9 +1312,19 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(isValid ? Icons.check_circle : Icons.circle_outlined, size: 14, color: isValid ? Colors.green : Colors.grey),
|
Icon(
|
||||||
|
isValid ? Icons.check_circle : Icons.circle_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: isValid ? Colors.green : Colors.grey,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(label, style: TextStyle(fontSize: 11, color: isValid ? Colors.green : Colors.grey)),
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: isValid ? Colors.green : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1276,8 +1332,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
bool canGoNext = false;
|
bool canGoNext = false;
|
||||||
if (_currentStep == 1 && _termsAccepted && _privacyAccepted) canGoNext = true;
|
if (_currentStep == 1 && _termsAccepted && _privacyAccepted)
|
||||||
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified) canGoNext = true;
|
canGoNext = true;
|
||||||
|
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified)
|
||||||
|
canGoNext = true;
|
||||||
if (_currentStep == 3) {
|
if (_currentStep == 3) {
|
||||||
final nameOk = _nameController.text.trim().isNotEmpty;
|
final nameOk = _nameController.text.trim().isNotEmpty;
|
||||||
if (_affiliationType == 'GENERAL') {
|
if (_affiliationType == 'GENERAL') {
|
||||||
@@ -1314,10 +1372,12 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: _currentStep == 1
|
child: _currentStep == 1
|
||||||
? _buildStepAgreement()
|
? _buildStepAgreement()
|
||||||
: (_currentStep == 2
|
: (_currentStep == 2
|
||||||
? _buildStepAuth()
|
? _buildStepAuth()
|
||||||
: (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())),
|
: (_currentStep == 3
|
||||||
|
? _buildStepInfo()
|
||||||
|
: _buildStepPassword())),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1329,7 +1389,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: () => setState(() => _currentStep--),
|
onPressed: () => setState(() => _currentStep--),
|
||||||
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55), side: const BorderSide(color: Colors.black)),
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(55),
|
||||||
|
side: const BorderSide(color: Colors.black),
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
tr('ui.common.prev', fallback: '이전'),
|
tr('ui.common.prev', fallback: '이전'),
|
||||||
style: const TextStyle(color: Colors.black),
|
style: const TextStyle(color: Colors.black),
|
||||||
@@ -1341,19 +1404,34 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: _currentStep < 4
|
onPressed: _currentStep < 4
|
||||||
? (canGoNext ? () => setState(() => _currentStep++) : null)
|
? (canGoNext
|
||||||
: (_isLoading ? null : _handleSignup),
|
? () => setState(() => _currentStep++)
|
||||||
|
: null)
|
||||||
|
: (_isLoading ? null : _handleSignup),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(55),
|
minimumSize: const Size.fromHeight(55),
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
),
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
? const SizedBox(
|
||||||
: Text(
|
height: 20,
|
||||||
_currentStep < 4
|
width: 20,
|
||||||
? tr('ui.userfront.signup.next_step', fallback: '다음 단계')
|
child: CircularProgressIndicator(
|
||||||
: tr('ui.userfront.signup.complete', fallback: '가입 완료'),
|
color: Colors.white,
|
||||||
),
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_currentStep < 4
|
||||||
|
? tr(
|
||||||
|
'ui.userfront.signup.next_step',
|
||||||
|
fallback: '다음 단계',
|
||||||
|
)
|
||||||
|
: tr(
|
||||||
|
'ui.userfront.signup.complete',
|
||||||
|
fallback: '가입 완료',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -18,22 +18,19 @@ String _envOrDefault(String key, String fallback) {
|
|||||||
String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||||
|
|
||||||
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
||||||
final queryParameters = <String, String>{
|
final queryParameters = <String, String>{'limit': '20'};
|
||||||
'limit': '20',
|
|
||||||
};
|
|
||||||
if (cursor != null && cursor.isNotEmpty) {
|
if (cursor != null && cursor.isNotEmpty) {
|
||||||
queryParameters['cursor'] = cursor;
|
queryParameters['cursor'] = cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/audit/auth/timeline')
|
final url = Uri.parse(
|
||||||
.replace(queryParameters: queryParameters);
|
'$_baseUrl/api/v1/audit/auth/timeline',
|
||||||
|
).replace(queryParameters: queryParameters);
|
||||||
final useCookie = AuthTokenStore.usesCookie();
|
final useCookie = AuthTokenStore.usesCookie();
|
||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
|
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
@@ -60,10 +57,6 @@ Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
|
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
|
||||||
|
|
||||||
final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) {
|
final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) {
|
||||||
@@ -86,11 +79,11 @@ class AuthTimelineState {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const AuthTimelineState.initial()
|
const AuthTimelineState.initial()
|
||||||
: items = const [],
|
: items = const [],
|
||||||
nextCursor = null,
|
nextCursor = null,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isLoadingMore = false,
|
isLoadingMore = false,
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
AuthTimelineState copyWith({
|
AuthTimelineState copyWith({
|
||||||
List<AuditLogEntry>? items,
|
List<AuditLogEntry>? items,
|
||||||
@@ -188,6 +181,7 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final authTimelineProvider = NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
|
final authTimelineProvider =
|
||||||
AuthTimelineNotifier.new,
|
NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
|
||||||
);
|
AuthTimelineNotifier.new,
|
||||||
|
);
|
||||||
|
|||||||
@@ -69,9 +69,7 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
|||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
|
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
@@ -106,6 +104,7 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final linkedRpsProvider = AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
|
final linkedRpsProvider =
|
||||||
return LinkedRpsNotifier();
|
AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
|
||||||
});
|
return LinkedRpsNotifier();
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,8 +68,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.dashboard.revoke.confirm',
|
'msg.userfront.dashboard.revoke.confirm',
|
||||||
fallback:
|
fallback: '{{app}} 앱과의 연동을 해지하시겠습니까?\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.',
|
||||||
'{{app}} 앱과의 연동을 해지하시겠습니까?\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.',
|
|
||||||
params: {'app': appName},
|
params: {'app': appName},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -81,8 +80,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
child:
|
child: Text(
|
||||||
Text(tr('ui.userfront.dashboard.revoke.confirm_button', fallback: '해지하기')),
|
tr(
|
||||||
|
'ui.userfront.dashboard.revoke.confirm_button',
|
||||||
|
fallback: '해지하기',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -158,31 +161,45 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
tr('ui.userfront.dashboard.scopes.title',
|
tr(
|
||||||
fallback: '권한 (Scopes)'),
|
'ui.userfront.dashboard.scopes.title',
|
||||||
|
fallback: '권한 (Scopes)',
|
||||||
|
),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (item.scopes.isEmpty)
|
if (item.scopes.isEmpty)
|
||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.dashboard.scopes.empty',
|
tr(
|
||||||
fallback: '요청된 권한이 없습니다.'),
|
'msg.userfront.dashboard.scopes.empty',
|
||||||
|
fallback: '요청된 권한이 없습니다.',
|
||||||
|
),
|
||||||
style: const TextStyle(color: Colors.grey),
|
style: const TextStyle(color: Colors.grey),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
children: item.scopes.map((s) => Chip(
|
children: item.scopes
|
||||||
label: Text(s, style: const TextStyle(fontSize: 12)),
|
.map(
|
||||||
visualDensity: VisualDensity.compact,
|
(s) => Chip(
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
label: Text(
|
||||||
)).toList(),
|
s,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
materialTapTargetSize:
|
||||||
|
MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
tr('ui.userfront.dashboard.status_history',
|
tr(
|
||||||
fallback: '상태 이력'),
|
'ui.userfront.dashboard.status_history',
|
||||||
|
fallback: '상태 이력',
|
||||||
|
),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -200,10 +217,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Builder(
|
Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final statusLabel = item.status == 'active'
|
final statusLabel = item.status == 'active'
|
||||||
? tr('ui.common.status.active',
|
? tr('ui.common.status.active', fallback: '활성')
|
||||||
fallback: '활성')
|
: tr(
|
||||||
: tr('ui.userfront.dashboard.status.revoked',
|
'ui.userfront.dashboard.status.revoked',
|
||||||
fallback: '해지됨');
|
fallback: '해지됨',
|
||||||
|
);
|
||||||
return Text(
|
return Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.dashboard.current_status',
|
'msg.userfront.dashboard.current_status',
|
||||||
@@ -242,8 +260,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.home_outlined),
|
leading: const Icon(Icons.home_outlined),
|
||||||
title:
|
title: Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
|
||||||
Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
|
|
||||||
selected: true,
|
selected: true,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (closeOnTap) {
|
if (closeOnTap) {
|
||||||
@@ -254,8 +271,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.person_outline),
|
leading: const Icon(Icons.person_outline),
|
||||||
title:
|
title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
|
||||||
Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
|
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (closeOnTap) {
|
if (closeOnTap) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
@@ -265,8 +281,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.qr_code_scanner),
|
leading: const Icon(Icons.qr_code_scanner),
|
||||||
title:
|
title: Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
|
||||||
Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
|
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (closeOnTap) {
|
if (closeOnTap) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
@@ -277,8 +292,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.logout),
|
leading: const Icon(Icons.logout),
|
||||||
title:
|
title: Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
|
||||||
Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
|
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (closeOnTap) {
|
if (closeOnTap) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
@@ -316,21 +330,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
Future<AuditPage> _fetchAuditLogs({String? cursor}) async {
|
Future<AuditPage> _fetchAuditLogs({String? cursor}) async {
|
||||||
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||||
final queryParameters = <String, String>{
|
final queryParameters = <String, String>{'limit': '20'};
|
||||||
'limit': '20',
|
|
||||||
};
|
|
||||||
if (cursor != null && cursor.isNotEmpty) {
|
if (cursor != null && cursor.isNotEmpty) {
|
||||||
queryParameters['cursor'] = cursor;
|
queryParameters['cursor'] = cursor;
|
||||||
}
|
}
|
||||||
final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline')
|
final url = Uri.parse(
|
||||||
.replace(queryParameters: queryParameters);
|
'$baseUrl/api/v1/audit/auth/timeline',
|
||||||
|
).replace(queryParameters: queryParameters);
|
||||||
final useCookie = AuthTokenStore.usesCookie();
|
final useCookie = AuthTokenStore.usesCookie();
|
||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
|
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
@@ -401,11 +412,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
if (parts.length != 3) {
|
if (parts.length != 3) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
|
final payload = utf8.decode(
|
||||||
|
base64Url.decode(base64Url.normalize(parts[1])),
|
||||||
|
);
|
||||||
final data = json.decode(payload) as Map<String, dynamic>;
|
final data = json.decode(payload) as Map<String, dynamic>;
|
||||||
final iatValue = data['iat'] ?? data['auth_time'];
|
final iatValue = data['iat'] ?? data['auth_time'];
|
||||||
if (iatValue is num) {
|
if (iatValue is num) {
|
||||||
return DateTime.fromMillisecondsSinceEpoch(iatValue.toInt() * 1000).toLocal();
|
return DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
iatValue.toInt() * 1000,
|
||||||
|
).toLocal();
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -467,9 +482,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) {
|
Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) {
|
||||||
final isOidc = authMethod.contains('OIDC');
|
final isOidc = authMethod.contains('OIDC');
|
||||||
if (authMethod != 'QR' && !isOidc) {
|
if (authMethod != 'QR' && !isOidc) {
|
||||||
final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? '';
|
final approvedUserAgent =
|
||||||
|
log.detailMap['approved_user_agent']?.toString() ?? '';
|
||||||
final approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
|
final approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
|
||||||
final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
|
final hasApproverMeta =
|
||||||
|
approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
|
||||||
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
|
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
|
||||||
return _selectableText(authMethod);
|
return _selectableText(authMethod);
|
||||||
}
|
}
|
||||||
@@ -497,7 +514,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final approvedSessionId = (log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ?? false)
|
final approvedSessionId =
|
||||||
|
(log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ??
|
||||||
|
false)
|
||||||
? log.detailMap['approved_session_id'].toString()
|
? log.detailMap['approved_session_id'].toString()
|
||||||
: log.sessionId;
|
: log.sessionId;
|
||||||
final tooltipLabel = isOidc
|
final tooltipLabel = isOidc
|
||||||
@@ -544,8 +563,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'),
|
isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent,
|
color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent,
|
||||||
decoration:
|
decoration: approvedSessionId.isEmpty
|
||||||
approvedSessionId.isEmpty ? null : TextDecoration.underline,
|
? null
|
||||||
|
: TextDecoration.underline,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -555,9 +575,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) {
|
Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) {
|
||||||
final isOidc = authMethod.contains('OIDC');
|
final isOidc = authMethod.contains('OIDC');
|
||||||
if (authMethod != 'QR' && !isOidc) {
|
if (authMethod != 'QR' && !isOidc) {
|
||||||
final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? '';
|
final approvedUserAgent =
|
||||||
|
log.detailMap['approved_user_agent']?.toString() ?? '';
|
||||||
final approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
|
final approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
|
||||||
final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
|
final hasApproverMeta =
|
||||||
|
approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
|
||||||
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
|
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
|
||||||
return _selectableText(
|
return _selectableText(
|
||||||
tr(
|
tr(
|
||||||
@@ -595,7 +617,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final approvedSessionId = (log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ?? false)
|
final approvedSessionId =
|
||||||
|
(log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ??
|
||||||
|
false)
|
||||||
? log.detailMap['approved_session_id'].toString()
|
? log.detailMap['approved_session_id'].toString()
|
||||||
: log.sessionId;
|
: log.sessionId;
|
||||||
final tooltipLabel = isOidc
|
final tooltipLabel = isOidc
|
||||||
@@ -642,7 +666,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
'msg.userfront.dashboard.auth_method',
|
'msg.userfront.dashboard.auth_method',
|
||||||
fallback: '인증수단: {{method}}',
|
fallback: '인증수단: {{method}}',
|
||||||
params: {
|
params: {
|
||||||
'method': isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'),
|
'method': isOidc
|
||||||
|
? authMethod
|
||||||
|
: tr('ui.common.qr', fallback: 'QR'),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -714,7 +740,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
final profileState = ref.watch(profileProvider);
|
final profileState = ref.watch(profileProvider);
|
||||||
final profile = profileState.value;
|
final profile = profileState.value;
|
||||||
final timelineState = ref.watch(authTimelineProvider);
|
final timelineState = ref.watch(authTimelineProvider);
|
||||||
final userName = profile?.name ??
|
final userName =
|
||||||
|
profile?.name ??
|
||||||
profile?.email ??
|
profile?.email ??
|
||||||
profile?.phone ??
|
profile?.phone ??
|
||||||
tr('ui.userfront.profile.user_fallback', fallback: 'User');
|
tr('ui.userfront.profile.user_fallback', fallback: 'User');
|
||||||
@@ -751,7 +778,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
drawer: isWide ? null : Drawer(child: _buildSideMenu(context, closeOnTap: true)),
|
drawer: isWide
|
||||||
|
? null
|
||||||
|
: Drawer(child: _buildSideMenu(context, closeOnTap: true)),
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
if (isWide)
|
if (isWide)
|
||||||
@@ -775,11 +804,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (!isMobile) ...[
|
if (!isMobile) ...[
|
||||||
_buildHeaderCard(userName, department, sessionIssuedAt),
|
_buildHeaderCard(
|
||||||
|
userName,
|
||||||
|
department,
|
||||||
|
sessionIssuedAt,
|
||||||
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
],
|
],
|
||||||
_buildSectionTitle(
|
_buildSectionTitle(
|
||||||
tr('ui.userfront.sections.apps', fallback: '나의 App 현황'),
|
tr(
|
||||||
|
'ui.userfront.sections.apps',
|
||||||
|
fallback: '나의 App 현황',
|
||||||
|
),
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.sections.apps_subtitle',
|
'msg.userfront.sections.apps_subtitle',
|
||||||
fallback: '현재 연결된 앱과 최근 인증 상태입니다.',
|
fallback: '현재 연결된 앱과 최근 인증 상태입니다.',
|
||||||
@@ -810,7 +846,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeaderCard(String userName, String department, DateTime? issuedAt) {
|
Widget _buildHeaderCard(
|
||||||
|
String userName,
|
||||||
|
String department,
|
||||||
|
DateTime? issuedAt,
|
||||||
|
) {
|
||||||
final sessionLabel = issuedAt != null
|
final sessionLabel = issuedAt != null
|
||||||
? _formatDateTime(issuedAt)
|
? _formatDateTime(issuedAt)
|
||||||
: tr('ui.userfront.session.unknown', fallback: '알 수 없음');
|
: tr('ui.userfront.session.unknown', fallback: '알 수 없음');
|
||||||
@@ -823,7 +863,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
fallback: '안녕하세요, {{name}}님',
|
fallback: '안녕하세요, {{name}}님',
|
||||||
params: {'name': userName},
|
params: {'name': userName},
|
||||||
),
|
),
|
||||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: _ink),
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _ink,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
@@ -871,13 +915,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _ink),
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: _ink,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
|
||||||
subtitle,
|
|
||||||
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -897,7 +942,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(fontSize: 12, color: _ink, fontWeight: FontWeight.w600),
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: _ink,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -920,7 +969,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
'msg.userfront.dashboard.activities.empty',
|
'msg.userfront.dashboard.activities.empty',
|
||||||
fallback: '연동된 앱이 없습니다.',
|
fallback: '연동된 앱이 없습니다.',
|
||||||
),
|
),
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600),
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
@@ -959,14 +1012,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
|
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
|
||||||
final items = <_ActivityItem>[];
|
final items = <_ActivityItem>[];
|
||||||
for (final rp in linkedRps) {
|
for (final rp in linkedRps) {
|
||||||
final normalizedStatus = rp.status.toLowerCase();
|
final normalizedStatus = rp.status.toLowerCase();
|
||||||
// status가 'inactive'로 내려올 수 있으므로 이를 반영
|
// status가 'inactive'로 내려올 수 있으므로 이를 반영
|
||||||
final isActiveInApi = normalizedStatus == 'active' || normalizedStatus == '';
|
final isActiveInApi =
|
||||||
|
normalizedStatus == 'active' || normalizedStatus == '';
|
||||||
final isRevoked = !isActiveInApi;
|
final isRevoked = !isActiveInApi;
|
||||||
|
|
||||||
final lastAuthLabel = rp.lastAuthenticatedAt != null
|
final lastAuthLabel = rp.lastAuthenticatedAt != null
|
||||||
@@ -1042,7 +1094,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
// 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려)
|
// 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려)
|
||||||
final double spacing = 12.0;
|
final double spacing = 12.0;
|
||||||
final double cardWidth = (maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount;
|
final double cardWidth =
|
||||||
|
(maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -1063,18 +1116,24 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
onPressed: () => setState(() => _showAllActivities = !_showAllActivities),
|
onPressed: () => setState(
|
||||||
|
() => _showAllActivities = !_showAllActivities,
|
||||||
|
),
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_showAllActivities ? Icons.keyboard_arrow_up : Icons.add,
|
_showAllActivities ? Icons.keyboard_arrow_up : Icons.add,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: _showAllActivities ? Colors.grey : Colors.blueAccent,
|
color: _showAllActivities
|
||||||
|
? Colors.grey
|
||||||
|
: Colors.blueAccent,
|
||||||
),
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
_showAllActivities
|
_showAllActivities
|
||||||
? tr('ui.common.collapse', fallback: '접기')
|
? tr('ui.common.collapse', fallback: '접기')
|
||||||
: tr('ui.common.show_more', fallback: '+ 더보기'),
|
: tr('ui.common.show_more', fallback: '+ 더보기'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: _showAllActivities ? Colors.grey : Colors.blueAccent,
|
color: _showAllActivities
|
||||||
|
? Colors.grey
|
||||||
|
: Colors.blueAccent,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1090,7 +1149,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) {
|
Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) {
|
||||||
final isActive = item.status == 'active';
|
final isActive = item.status == 'active';
|
||||||
final statusColor = isActive ? Colors.green : Colors.grey;
|
final statusColor = isActive ? Colors.green : Colors.grey;
|
||||||
final borderColor = isActive ? Colors.green.withValues(alpha: 128) : _border;
|
final borderColor = isActive
|
||||||
|
? Colors.green.withValues(alpha: 128)
|
||||||
|
: _border;
|
||||||
final borderWidth = isActive ? 1.5 : 1.0;
|
final borderWidth = isActive ? 1.5 : 1.0;
|
||||||
|
|
||||||
// 활성 상태면 클릭 가능 (URL 유무와 관계없이)
|
// 활성 상태면 클릭 가능 (URL 유무와 관계없이)
|
||||||
@@ -1104,13 +1165,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
color: _surface,
|
color: _surface,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: borderColor, width: borderWidth),
|
border: Border.all(color: borderColor, width: borderWidth),
|
||||||
boxShadow: isActive ? [
|
boxShadow: isActive
|
||||||
BoxShadow(
|
? [
|
||||||
color: Colors.green.withValues(alpha: 13),
|
BoxShadow(
|
||||||
blurRadius: 10,
|
color: Colors.green.withValues(alpha: 13),
|
||||||
offset: const Offset(0, 4),
|
blurRadius: 10,
|
||||||
)
|
offset: const Offset(0, 4),
|
||||||
] : null,
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -1120,7 +1183,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
item.appName,
|
item.appName,
|
||||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _ink),
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: _ink,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
@@ -1132,8 +1199,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
item.status == 'active'
|
item.status == 'active'
|
||||||
? tr('ui.common.status.active', fallback: '활성')
|
? tr('ui.common.status.active', fallback: '활성')
|
||||||
: tr('ui.userfront.dashboard.status.revoked', fallback: '해지됨'),
|
: tr(
|
||||||
style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w600),
|
'ui.userfront.dashboard.status.revoked',
|
||||||
|
fallback: '해지됨',
|
||||||
|
),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: statusColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1146,7 +1220,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
item.lastAuthAt,
|
item.lastAuthAt,
|
||||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: _ink),
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: _ink,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
@@ -1168,22 +1246,38 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: (_isRevoking || item.isRevoked) ? null : item.onRevoke,
|
onPressed: (_isRevoking || item.isRevoked)
|
||||||
|
? null
|
||||||
|
: item.onRevoke,
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: item.isRevoked ? Colors.grey : Colors.redAccent,
|
foregroundColor: item.isRevoked
|
||||||
side: BorderSide(color: item.isRevoked ? Colors.grey : Colors.redAccent, width: 0.5),
|
? Colors.grey
|
||||||
|
: Colors.redAccent,
|
||||||
|
side: BorderSide(
|
||||||
|
color: item.isRevoked ? Colors.grey : Colors.redAccent,
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
),
|
),
|
||||||
child: _isRevoking && !item.isRevoked
|
child: _isRevoking && !item.isRevoked
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 14,
|
width: 14,
|
||||||
height: 14,
|
height: 14,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.redAccent),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.redAccent,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
item.isRevoked
|
item.isRevoked
|
||||||
? tr('ui.userfront.dashboard.status.revoked', fallback: '해지됨')
|
? tr(
|
||||||
: tr('ui.userfront.dashboard.revoke.title', fallback: '연동 해지'),
|
'ui.userfront.dashboard.status.revoked',
|
||||||
|
fallback: '해지됨',
|
||||||
|
)
|
||||||
|
: tr(
|
||||||
|
'ui.userfront.dashboard.revoke.title',
|
||||||
|
fallback: '연동 해지',
|
||||||
|
),
|
||||||
style: const TextStyle(fontSize: 13),
|
style: const TextStyle(fontSize: 13),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1268,7 +1362,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => ref.read(authTimelineProvider.notifier).refresh(),
|
onPressed: () =>
|
||||||
|
ref.read(authTimelineProvider.notifier).refresh(),
|
||||||
child: Text(tr('ui.common.retry', fallback: '다시 시도')),
|
child: Text(tr('ui.common.retry', fallback: '다시 시도')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1326,7 +1421,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
columns: [
|
columns: [
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(
|
label: Text(
|
||||||
tr('ui.userfront.audit.table.session_id', fallback: 'Session ID'),
|
tr(
|
||||||
|
'ui.userfront.audit.table.session_id',
|
||||||
|
fallback: 'Session ID',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
@@ -1336,7 +1434,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(
|
label: Text(
|
||||||
tr('ui.userfront.audit.table.app', fallback: '애플리케이션'),
|
tr(
|
||||||
|
'ui.userfront.audit.table.app',
|
||||||
|
fallback: '애플리케이션',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
@@ -1346,17 +1447,26 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(
|
label: Text(
|
||||||
tr('ui.userfront.audit.table.device', fallback: '접속환경'),
|
tr(
|
||||||
|
'ui.userfront.audit.table.device',
|
||||||
|
fallback: '접속환경',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(
|
label: Text(
|
||||||
tr('ui.userfront.audit.table.auth_method', fallback: '인증수단'),
|
tr(
|
||||||
|
'ui.userfront.audit.table.auth_method',
|
||||||
|
fallback: '인증수단',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(
|
label: Text(
|
||||||
tr('ui.userfront.audit.table.result', fallback: '인증결과'),
|
tr(
|
||||||
|
'ui.userfront.audit.table.result',
|
||||||
|
fallback: '인증결과',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
@@ -1369,47 +1479,57 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
final statusLabel = log.status == 'success'
|
final statusLabel = log.status == 'success'
|
||||||
? tr('ui.common.status.success', fallback: '성공')
|
? tr('ui.common.status.success', fallback: '성공')
|
||||||
: tr('ui.common.status.failure', fallback: '실패');
|
: tr('ui.common.status.failure', fallback: '실패');
|
||||||
final statusColor =
|
final statusColor = log.status == 'success'
|
||||||
log.status == 'success' ? Colors.green : Colors.redAccent;
|
? Colors.green
|
||||||
|
: Colors.redAccent;
|
||||||
final authMethod = log.authMethod.isNotEmpty
|
final authMethod = log.authMethod.isNotEmpty
|
||||||
? log.authMethod
|
? log.authMethod
|
||||||
: _authMethodLabel();
|
: _authMethodLabel();
|
||||||
final deviceLabel = _deviceLabelFromUserAgent(log.userAgent);
|
final deviceLabel = _deviceLabelFromUserAgent(
|
||||||
return DataRow(cells: [
|
log.userAgent,
|
||||||
DataCell(
|
);
|
||||||
_selectableText(
|
return DataRow(
|
||||||
log.sessionId.isEmpty
|
cells: [
|
||||||
? tr('ui.common.hyphen', fallback: '-')
|
DataCell(
|
||||||
: log.sessionId,
|
_selectableText(
|
||||||
),
|
log.sessionId.isEmpty
|
||||||
),
|
? tr('ui.common.hyphen', fallback: '-')
|
||||||
DataCell(_selectableText(_formatDateTime(log.timestamp))),
|
: log.sessionId,
|
||||||
DataCell(_buildAppCell(log)),
|
|
||||||
DataCell(
|
|
||||||
_selectableText(
|
|
||||||
log.ipAddress.isEmpty
|
|
||||||
? tr('ui.common.hyphen', fallback: '-')
|
|
||||||
: log.ipAddress,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
DataCell(_selectableText(deviceLabel)),
|
|
||||||
DataCell(_buildAuthMethodCell(log, authMethod)),
|
|
||||||
DataCell(
|
|
||||||
_selectableText(
|
|
||||||
statusLabel,
|
|
||||||
style: TextStyle(
|
|
||||||
color: statusColor,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
DataCell(
|
||||||
DataCell(
|
_selectableText(_formatDateTime(log.timestamp)),
|
||||||
_selectableText(
|
|
||||||
tr('ui.userfront.audit.table.pending', fallback: '(준비중)'),
|
|
||||||
style: const TextStyle(color: Colors.grey),
|
|
||||||
),
|
),
|
||||||
),
|
DataCell(_buildAppCell(log)),
|
||||||
]);
|
DataCell(
|
||||||
|
_selectableText(
|
||||||
|
log.ipAddress.isEmpty
|
||||||
|
? tr('ui.common.hyphen', fallback: '-')
|
||||||
|
: log.ipAddress,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataCell(_selectableText(deviceLabel)),
|
||||||
|
DataCell(_buildAuthMethodCell(log, authMethod)),
|
||||||
|
DataCell(
|
||||||
|
_selectableText(
|
||||||
|
statusLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
color: statusColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataCell(
|
||||||
|
_selectableText(
|
||||||
|
tr(
|
||||||
|
'ui.userfront.audit.table.pending',
|
||||||
|
fallback: '(준비중)',
|
||||||
|
),
|
||||||
|
style: const TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1443,7 +1563,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: _buildAppCell(
|
child: _buildAppCell(
|
||||||
log,
|
log,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600, color: _ink),
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: _ink,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_selectableText(
|
_selectableText(
|
||||||
@@ -1451,7 +1574,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
? tr('ui.common.status.success', fallback: '성공')
|
? tr('ui.common.status.success', fallback: '성공')
|
||||||
: tr('ui.common.status.failure', fallback: '실패'),
|
: tr('ui.common.status.failure', fallback: '실패'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: log.status == 'success' ? Colors.green : Colors.redAccent,
|
color: log.status == 'success'
|
||||||
|
? Colors.green
|
||||||
|
: Colors.redAccent,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1491,10 +1616,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
tr(
|
tr(
|
||||||
'msg.userfront.audit.device',
|
'msg.userfront.audit.device',
|
||||||
fallback: '접속환경: {{value}}',
|
fallback: '접속환경: {{value}}',
|
||||||
params: {'value': _deviceLabelFromUserAgent(log.userAgent)},
|
params: {
|
||||||
|
'value': _deviceLabelFromUserAgent(log.userAgent),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildAuthMethodLine(log, log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel()),
|
_buildAuthMethodLine(
|
||||||
|
log,
|
||||||
|
log.authMethod.isNotEmpty
|
||||||
|
? log.authMethod
|
||||||
|
: _authMethodLabel(),
|
||||||
|
),
|
||||||
_selectableText(
|
_selectableText(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.audit.result',
|
'msg.userfront.audit.result',
|
||||||
@@ -1507,10 +1639,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_selectableText(
|
_selectableText(
|
||||||
tr(
|
tr('msg.userfront.audit.status', fallback: '현황: (준비중)'),
|
||||||
'msg.userfront.audit.status',
|
|
||||||
fallback: '현황: (준비중)',
|
|
||||||
),
|
|
||||||
style: TextStyle(color: Colors.grey[600]),
|
style: TextStyle(color: Colors.grey[600]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1542,7 +1671,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => ref.read(authTimelineProvider.notifier).loadMore(),
|
onPressed: () =>
|
||||||
|
ref.read(authTimelineProvider.notifier).loadMore(),
|
||||||
child: Text(tr('ui.common.retry', fallback: '재시도')),
|
child: Text(tr('ui.common.retry', fallback: '재시도')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
if (_editingField != 'name' && _nameController!.text != profile.name) {
|
if (_editingField != 'name' && _nameController!.text != profile.name) {
|
||||||
_nameController!.text = profile.name;
|
_nameController!.text = profile.name;
|
||||||
}
|
}
|
||||||
if (_editingField != 'department' && _departmentController!.text != profile.department) {
|
if (_editingField != 'department' &&
|
||||||
|
_departmentController!.text != profile.department) {
|
||||||
_departmentController!.text = profile.department;
|
_departmentController!.text = profile.department;
|
||||||
}
|
}
|
||||||
if (_editingField != 'phone' && _phoneController!.text != profile.phone) {
|
if (_editingField != 'phone' && _phoneController!.text != profile.phone) {
|
||||||
@@ -274,10 +275,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr('msg.userfront.profile.phone.verified', fallback: '인증되었습니다.'),
|
||||||
'msg.userfront.profile.phone.verified',
|
|
||||||
fallback: '인증되었습니다.',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -310,24 +308,30 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
final confirmPassword = _confirmPasswordController?.text.trim() ?? '';
|
final confirmPassword = _confirmPasswordController?.text.trim() ?? '';
|
||||||
|
|
||||||
if (currentPassword.isEmpty) {
|
if (currentPassword.isEmpty) {
|
||||||
setState(() => _passwordError = tr(
|
setState(
|
||||||
'msg.userfront.profile.password.current_required',
|
() => _passwordError = tr(
|
||||||
fallback: '현재 비밀번호를 입력해 주세요.',
|
'msg.userfront.profile.password.current_required',
|
||||||
));
|
fallback: '현재 비밀번호를 입력해 주세요.',
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword.isEmpty) {
|
if (newPassword.isEmpty) {
|
||||||
setState(() => _passwordError = tr(
|
setState(
|
||||||
'msg.userfront.profile.password.new_required',
|
() => _passwordError = tr(
|
||||||
fallback: '새 비밀번호를 입력해 주세요.',
|
'msg.userfront.profile.password.new_required',
|
||||||
));
|
fallback: '새 비밀번호를 입력해 주세요.',
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword != confirmPassword) {
|
if (newPassword != confirmPassword) {
|
||||||
setState(() => _passwordError = tr(
|
setState(
|
||||||
'msg.userfront.profile.password.mismatch',
|
() => _passwordError = tr(
|
||||||
fallback: '새 비밀번호가 일치하지 않습니다.',
|
'msg.userfront.profile.password.mismatch',
|
||||||
));
|
fallback: '새 비밀번호가 일치하지 않습니다.',
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +342,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ref.read(profileRepositoryProvider).changePassword(
|
await ref
|
||||||
|
.read(profileRepositoryProvider)
|
||||||
|
.changePassword(
|
||||||
currentPassword: currentPassword,
|
currentPassword: currentPassword,
|
||||||
newPassword: newPassword,
|
newPassword: newPassword,
|
||||||
);
|
);
|
||||||
@@ -434,10 +440,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr('msg.userfront.profile.name_required', fallback: '이름을 입력해주세요.'),
|
||||||
'msg.userfront.profile.name_required',
|
|
||||||
fallback: '이름을 입력해주세요.',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -500,7 +503,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_isSavingField = true;
|
_isSavingField = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ref.read(profileProvider.notifier).updateProfile(
|
await ref
|
||||||
|
.read(profileProvider.notifier)
|
||||||
|
.updateProfile(
|
||||||
name: nextName,
|
name: nextName,
|
||||||
phone: nextPhone,
|
phone: nextPhone,
|
||||||
department: nextDepartment,
|
department: nextDepartment,
|
||||||
@@ -551,32 +556,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.home_outlined),
|
leading: const Icon(Icons.home_outlined),
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
|
||||||
tr('ui.userfront.nav.dashboard', fallback: '대시보드'),
|
|
||||||
),
|
|
||||||
onTap: () => context.go('/'),
|
onTap: () => context.go('/'),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.person_outline),
|
leading: const Icon(Icons.person_outline),
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
|
||||||
tr('ui.userfront.nav.profile', fallback: '내 정보'),
|
|
||||||
),
|
|
||||||
selected: true,
|
selected: true,
|
||||||
onTap: () => context.go('/profile'),
|
onTap: () => context.go('/profile'),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.qr_code_scanner),
|
leading: const Icon(Icons.qr_code_scanner),
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
|
||||||
tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔'),
|
|
||||||
),
|
|
||||||
onTap: () => context.go('/scan'),
|
onTap: () => context.go('/scan'),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.logout),
|
leading: const Icon(Icons.logout),
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
|
||||||
tr('ui.userfront.nav.logout', fallback: '로그아웃'),
|
|
||||||
),
|
|
||||||
onTap: _logout,
|
onTap: _logout,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -589,13 +586,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _ink),
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: _ink,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
|
||||||
subtitle,
|
|
||||||
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -615,7 +613,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(fontSize: 12, color: _ink, fontWeight: FontWeight.w600),
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: _ink,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -650,10 +652,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const CircleAvatar(
|
const CircleAvatar(radius: 32, child: Icon(Icons.person, size: 32)),
|
||||||
radius: 32,
|
|
||||||
child: Icon(Icons.person, size: 32),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -672,7 +671,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(email, style: TextStyle(color: Colors.grey[600], fontSize: 14)),
|
Text(
|
||||||
|
email,
|
||||||
|
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
@@ -682,7 +684,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
Icons.badge_outlined,
|
Icons.badge_outlined,
|
||||||
tr('ui.userfront.profile.manage', fallback: '프로필 관리'),
|
tr('ui.userfront.profile.manage', fallback: '프로필 관리'),
|
||||||
),
|
),
|
||||||
_buildInfoChip(Icons.apartment, profile.tenant?.name ?? department),
|
_buildInfoChip(
|
||||||
|
Icons.apartment,
|
||||||
|
profile.tenant?.name ?? department,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -787,9 +792,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.profile.phone.title', fallback: '전화번호')),
|
||||||
tr('ui.userfront.profile.phone.title', fallback: '전화번호'),
|
|
||||||
),
|
|
||||||
subtitle: Text(displayValue),
|
subtitle: Text(displayValue),
|
||||||
trailing: TextButton(
|
trailing: TextButton(
|
||||||
onPressed: isUpdating ? null : () => _startEditing('phone', profile),
|
onPressed: isUpdating ? null : () => _startEditing('phone', profile),
|
||||||
@@ -918,7 +921,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(_showCurrentPassword ? Icons.visibility_off : Icons.visibility),
|
icon: Icon(
|
||||||
|
_showCurrentPassword
|
||||||
|
? Icons.visibility_off
|
||||||
|
: Icons.visibility,
|
||||||
|
),
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
_showCurrentPassword = !_showCurrentPassword;
|
_showCurrentPassword = !_showCurrentPassword;
|
||||||
}),
|
}),
|
||||||
@@ -936,7 +943,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(_showNewPassword ? Icons.visibility_off : Icons.visibility),
|
icon: Icon(
|
||||||
|
_showNewPassword ? Icons.visibility_off : Icons.visibility,
|
||||||
|
),
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
_showNewPassword = !_showNewPassword;
|
_showNewPassword = !_showNewPassword;
|
||||||
}),
|
}),
|
||||||
@@ -954,7 +963,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(_showConfirmPassword ? Icons.visibility_off : Icons.visibility),
|
icon: Icon(
|
||||||
|
_showConfirmPassword
|
||||||
|
? Icons.visibility_off
|
||||||
|
: Icons.visibility,
|
||||||
|
),
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
_showConfirmPassword = !_showConfirmPassword;
|
_showConfirmPassword = !_showConfirmPassword;
|
||||||
}),
|
}),
|
||||||
@@ -963,10 +976,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
if (_passwordError != null) ...[
|
if (_passwordError != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(_passwordError!, style: const TextStyle(color: Colors.red)),
|
||||||
_passwordError!,
|
|
||||||
style: const TextStyle(color: Colors.red),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
if (_passwordSuccess != null) ...[
|
if (_passwordSuccess != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -1037,7 +1047,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
_buildEditableTile(
|
_buildEditableTile(
|
||||||
field: 'name',
|
field: 'name',
|
||||||
label: tr('ui.userfront.profile.field.name', fallback: '이름'),
|
label: tr(
|
||||||
|
'ui.userfront.profile.field.name',
|
||||||
|
fallback: '이름',
|
||||||
|
),
|
||||||
value: profile.name,
|
value: profile.name,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
isUpdating: isUpdating,
|
isUpdating: isUpdating,
|
||||||
@@ -1045,7 +1058,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildReadOnlyTile(
|
_buildReadOnlyTile(
|
||||||
tr('ui.userfront.profile.field.email', fallback: '이메일'),
|
tr(
|
||||||
|
'ui.userfront.profile.field.email',
|
||||||
|
fallback: '이메일',
|
||||||
|
),
|
||||||
profile.email,
|
profile.email,
|
||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
@@ -1055,7 +1071,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
_buildSectionTitle(
|
_buildSectionTitle(
|
||||||
tr('ui.userfront.profile.section.organization', fallback: '조직 정보'),
|
tr(
|
||||||
|
'ui.userfront.profile.section.organization',
|
||||||
|
fallback: '조직 정보',
|
||||||
|
),
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.section.organization',
|
'msg.userfront.profile.section.organization',
|
||||||
fallback: '소속 및 구분 정보입니다.',
|
fallback: '소속 및 구분 정보입니다.',
|
||||||
@@ -1067,7 +1086,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
_buildEditableTile(
|
_buildEditableTile(
|
||||||
field: 'department',
|
field: 'department',
|
||||||
label: tr('ui.userfront.profile.field.department', fallback: '소속'),
|
label: tr(
|
||||||
|
'ui.userfront.profile.field.department',
|
||||||
|
fallback: '소속',
|
||||||
|
),
|
||||||
value: profile.department,
|
value: profile.department,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
isUpdating: isUpdating,
|
isUpdating: isUpdating,
|
||||||
@@ -1075,7 +1097,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildReadOnlyTile(
|
_buildReadOnlyTile(
|
||||||
tr('ui.userfront.profile.field.affiliation', fallback: '구분'),
|
tr(
|
||||||
|
'ui.userfront.profile.field.affiliation',
|
||||||
|
fallback: '구분',
|
||||||
|
),
|
||||||
profile.affiliationType,
|
profile.affiliationType,
|
||||||
),
|
),
|
||||||
if (profile.tenant != null) ...[
|
if (profile.tenant != null) ...[
|
||||||
@@ -1091,7 +1116,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
if (profile.companyCode.isNotEmpty) ...[
|
if (profile.companyCode.isNotEmpty) ...[
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildReadOnlyTile(
|
_buildReadOnlyTile(
|
||||||
tr('ui.userfront.profile.field.company_code', fallback: '회사코드'),
|
tr(
|
||||||
|
'ui.userfront.profile.field.company_code',
|
||||||
|
fallback: '회사코드',
|
||||||
|
),
|
||||||
profile.companyCode,
|
profile.companyCode,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1148,7 +1176,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => ref.read(profileProvider.notifier).loadProfile(),
|
onPressed: () =>
|
||||||
|
ref.read(profileProvider.notifier).loadProfile(),
|
||||||
child: Text(tr('ui.common.retry', fallback: '재시도')),
|
child: Text(tr('ui.common.retry', fallback: '재시도')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1193,11 +1222,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
|
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
if (isWide)
|
if (isWide) SizedBox(width: 240, child: _buildSideMenu(context)),
|
||||||
SizedBox(
|
|
||||||
width: 240,
|
|
||||||
child: _buildSideMenu(context),
|
|
||||||
),
|
|
||||||
Expanded(child: _buildContent(profile, isUpdating)),
|
Expanded(child: _buildContent(profile, isUpdating)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -46,7 +46,9 @@ void main() async {
|
|||||||
FlutterError.presentError(details);
|
FlutterError.presentError(details);
|
||||||
_log.severe("FLUTTER_ERROR", details.exception, details.stack);
|
_log.severe("FLUTTER_ERROR", details.exception, details.stack);
|
||||||
// Also send to backend if needed
|
// Also send to backend if needed
|
||||||
AuthProxyService.logError("FLUTTER_ERROR: ${details.exception}\n${details.stack}");
|
AuthProxyService.logError(
|
||||||
|
"FLUTTER_ERROR: ${details.exception}\n${details.stack}",
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PlatformDispatcher.instance.onError = (error, stack) {
|
PlatformDispatcher.instance.onError = (error, stack) {
|
||||||
@@ -86,16 +88,17 @@ final _router = GoRouter(
|
|||||||
return const DashboardScreen();
|
return const DashboardScreen();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(path: '/profile', builder: (context, state) => const ProfilePage()),
|
||||||
path: '/profile',
|
|
||||||
builder: (context, state) => const ProfilePage(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/signin',
|
path: '/signin',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final loginChallenge = state.uri.queryParameters['login_challenge'];
|
final loginChallenge = state.uri.queryParameters['login_challenge'];
|
||||||
final redirectUrl = state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_url'];
|
final redirectUrl =
|
||||||
_routerLogger.info("Navigating to /signin with login_challenge: $loginChallenge, redirect: $redirectUrl");
|
state.uri.queryParameters['redirect_uri'] ??
|
||||||
|
state.uri.queryParameters['redirect_url'];
|
||||||
|
_routerLogger.info(
|
||||||
|
"Navigating to /signin with login_challenge: $loginChallenge, redirect: $redirectUrl",
|
||||||
|
);
|
||||||
return LoginScreen(
|
return LoginScreen(
|
||||||
key: state.pageKey,
|
key: state.pageKey,
|
||||||
loginChallenge: loginChallenge,
|
loginChallenge: loginChallenge,
|
||||||
@@ -106,12 +109,11 @@ final _router = GoRouter(
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/login',
|
path: '/login',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final redirectUrl = state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_url'];
|
final redirectUrl =
|
||||||
|
state.uri.queryParameters['redirect_uri'] ??
|
||||||
|
state.uri.queryParameters['redirect_url'];
|
||||||
_routerLogger.info("Navigating to /login, redirect: $redirectUrl");
|
_routerLogger.info("Navigating to /login, redirect: $redirectUrl");
|
||||||
return LoginScreen(
|
return LoginScreen(key: state.pageKey, redirectUrl: redirectUrl);
|
||||||
key: state.pageKey,
|
|
||||||
redirectUrl: redirectUrl,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
@@ -120,7 +122,9 @@ final _router = GoRouter(
|
|||||||
final consentChallenge = state.uri.queryParameters['consent_challenge'];
|
final consentChallenge = state.uri.queryParameters['consent_challenge'];
|
||||||
if (consentChallenge == null) {
|
if (consentChallenge == null) {
|
||||||
_routerLogger.warning("Consent screen loaded without a challenge.");
|
_routerLogger.warning("Consent screen loaded without a challenge.");
|
||||||
return const Scaffold(body: Center(child: Text('Error: Consent challenge is missing.')));
|
return const Scaffold(
|
||||||
|
body: Center(child: Text('Error: Consent challenge is missing.')),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_routerLogger.info("Navigating to /consent with challenge.");
|
_routerLogger.info("Navigating to /consent with challenge.");
|
||||||
return ConsentScreen(consentChallenge: consentChallenge);
|
return ConsentScreen(consentChallenge: consentChallenge);
|
||||||
@@ -257,21 +261,22 @@ final _router = GoRouter(
|
|||||||
final path = state.uri.path;
|
final path = state.uri.path;
|
||||||
|
|
||||||
// Public paths that don't require login
|
// Public paths that don't require login
|
||||||
final isPublicPath = path == '/signin' ||
|
final isPublicPath =
|
||||||
path == '/signup' ||
|
path == '/signin' ||
|
||||||
path == '/login' ||
|
path == '/signup' ||
|
||||||
path == '/registration' ||
|
path == '/login' ||
|
||||||
path == '/verify' ||
|
path == '/registration' ||
|
||||||
path == '/verification' ||
|
path == '/verify' ||
|
||||||
path.startsWith('/verify/') ||
|
path == '/verification' ||
|
||||||
path == '/approve' ||
|
path.startsWith('/verify/') ||
|
||||||
path.startsWith('/ql/') ||
|
path == '/approve' ||
|
||||||
path == '/forgot-password' ||
|
path.startsWith('/ql/') ||
|
||||||
path == '/recovery' ||
|
path == '/forgot-password' ||
|
||||||
path == '/reset-password' ||
|
path == '/recovery' ||
|
||||||
path == '/error' ||
|
path == '/reset-password' ||
|
||||||
path == '/settings' ||
|
path == '/error' ||
|
||||||
path == '/consent'; // Consent page is public
|
path == '/settings' ||
|
||||||
|
path == '/consent'; // Consent page is public
|
||||||
|
|
||||||
_routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");
|
_routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");
|
||||||
|
|
||||||
@@ -282,7 +287,7 @@ final _router = GoRouter(
|
|||||||
|
|
||||||
// If not logged in and trying to access a protected page, redirect to /signin
|
// If not logged in and trying to access a protected page, redirect to /signin
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
_routerLogger.info("Not logged in, redirecting to /signin");
|
_routerLogger.info("Not logged in, redirecting to /signin");
|
||||||
// Preserve OIDC challenge if present
|
// Preserve OIDC challenge if present
|
||||||
final loginChallenge = state.uri.queryParameters['login_challenge'];
|
final loginChallenge = state.uri.queryParameters['login_challenge'];
|
||||||
if (loginChallenge != null) {
|
if (loginChallenge != null) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<!--
|
<!--
|
||||||
If you are serving your web app in a path other than the root, change the
|
If you are serving your web app in a path other than the root, change the
|
||||||
href value below to reflect the base path you are serving from.
|
href value below to reflect the base path you are serving from.
|
||||||
|
|
||||||
@@ -14,29 +14,29 @@
|
|||||||
This is a placeholder for base href that will be replaced by the value of
|
This is a placeholder for base href that will be replaced by the value of
|
||||||
the `--base-href` argument provided to `flutter build`.
|
the `--base-href` argument provided to `flutter build`.
|
||||||
-->
|
-->
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
|
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible" />
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible" />
|
||||||
<meta name="description" content="바론 SW 포털" />
|
<meta name="description" content="바론 SW 포털" />
|
||||||
|
|
||||||
<!-- iOS meta tags & icons -->
|
<!-- iOS meta tags & icons -->
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Baron 로그인" />
|
<meta name="apple-mobile-web-app-title" content="Baron 로그인" />
|
||||||
<link rel="apple-touch-icon" href="icons/Icon-192.png" />
|
<link rel="apple-touch-icon" href="icons/Icon-192.png" />
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
|
|
||||||
<title>Baron 로그인</title>
|
<title>Baron 로그인</title>
|
||||||
<link rel="manifest" href="manifest.json" />
|
<link rel="manifest" href="manifest.json" />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="flutter_bootstrap.js" async></script>
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "Baron 로그인",
|
"name": "Baron 로그인",
|
||||||
"short_name": "Baron 로그인",
|
"short_name": "Baron 로그인",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0175C2",
|
"background_color": "#0175C2",
|
||||||
"theme_color": "#0175C2",
|
"theme_color": "#0175C2",
|
||||||
"description": "Baron 로그인 사용자 포털.",
|
"description": "Baron 로그인 사용자 포털.",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "icons/Icon-192.png",
|
"src": "icons/Icon-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/Icon-512.png",
|
"src": "icons/Icon-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/Icon-maskable-192.png",
|
"src": "icons/Icon-maskable-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/Icon-maskable-512.png",
|
"src": "icons/Icon-maskable-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user