Skip to content

Commit a771066

Browse files
committed
fix: DataTable error/403 state (T1-2)
2 parents 62a3ef5 + 209e41a commit a771066

10 files changed

Lines changed: 100 additions & 7 deletions

File tree

web/admin/src/components/ui/DataTable.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { isForbidden } from "@remnacore/shared";
12
import type { ReactNode } from "react";
3+
import { useTranslation } from "react-i18next";
24

35
export type Column<T> = {
46
key: string;
@@ -14,6 +16,8 @@ export function DataTable<T>({
1416
rowKey,
1517
onRowClick,
1618
empty = "NO DATA",
19+
error,
20+
onRetry,
1721
}: {
1822
columns: Column<T>[];
1923
rows: T[];
@@ -22,7 +26,12 @@ export function DataTable<T>({
2226
rowKey: (r: T) => string;
2327
onRowClick?: (r: T) => void;
2428
empty?: string;
29+
/** Fetch error, if any. Rendered as a distinct state — NOT the empty state. */
30+
error?: unknown;
31+
/** Retry callback (e.g. react-query refetch); shown for non-403 errors. */
32+
onRetry?: () => void;
2533
}) {
34+
const { t } = useTranslation();
2635
const grid = { gridTemplateColumns: cols, gap: "14px" } as const;
2736
return (
2837
<section className="border border-line bg-surface">
@@ -36,7 +45,28 @@ export function DataTable<T>({
3645
</span>
3746
))}
3847
</div>
39-
{rows.length === 0 ? (
48+
{error ? (
49+
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
50+
<span
51+
className={`text-[11px] uppercase tracking-[1.5px] ${
52+
isForbidden(error) ? "text-warn" : "text-danger"
53+
}`}
54+
>
55+
{isForbidden(error)
56+
? t("common.accessDenied")
57+
: t("common.loadFailed")}
58+
</span>
59+
{!isForbidden(error) && onRetry && (
60+
<button
61+
type="button"
62+
onClick={onRetry}
63+
className="border border-line px-3 py-1.5 text-[10px] uppercase tracking-[1px] text-t3 hover:bg-white/[.03]"
64+
>
65+
{t("common.retry")}
66+
</button>
67+
)}
68+
</div>
69+
) : rows.length === 0 ? (
4070
<div className="px-4 py-10 text-center text-[11px] uppercase tracking-[2px] text-t7">
4171
{empty}
4272
</div>

web/admin/src/routes/invoices.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ function statusTone(status: InvoiceStatus): Tone {
2424
export function AdminInvoicesPage() {
2525
const { t } = useTranslation();
2626
const [pagination, setPagination] = useState({ limit: 50, offset: 0 });
27-
const { data: invoices, isLoading } = useAdminInvoices(pagination);
27+
const {
28+
data: invoices,
29+
isLoading,
30+
error,
31+
refetch,
32+
} = useAdminInvoices(pagination);
2833

2934
const columns: Column<Invoice>[] = [
3035
{
@@ -89,6 +94,8 @@ export function AdminInvoicesPage() {
8994
<DataTable<Invoice>
9095
columns={columns}
9196
rows={isLoading ? [] : (invoices ?? [])}
97+
error={error}
98+
onRetry={refetch}
9299
cols=".9fr 1.1fr .9fr 1fr 1.1fr 1.1fr"
93100
rowKey={(inv) => inv.id}
94101
empty={isLoading ? t("common.loading").toUpperCase() : "NO INVOICES"}

web/admin/src/routes/reseller/commissions.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ import { NoShop } from "./dashboard";
1717
export function ResellerCommissionsPage() {
1818
const { t } = useTranslation();
1919
const { activeShopId } = useShopStore();
20-
const { data: commissions, isLoading } = useResellerCommissions(activeShopId);
20+
const {
21+
data: commissions,
22+
isLoading,
23+
error,
24+
refetch,
25+
} = useResellerCommissions(activeShopId);
2126

2227
if (!activeShopId) {
2328
return <NoShop title={t("reseller.commissions.title")} />;
@@ -63,6 +68,8 @@ export function ResellerCommissionsPage() {
6368
<DataTable
6469
columns={columns}
6570
rows={commissions ?? []}
71+
error={error}
72+
onRetry={refetch}
6673
cols="1.4fr .8fr .8fr 1fr"
6774
rowKey={(c) => c.id}
6875
empty={

web/admin/src/routes/reseller/customers.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import { NoShop } from "./dashboard";
1111
export function ResellerCustomersPage() {
1212
const { t } = useTranslation();
1313
const { activeShopId } = useShopStore();
14-
const { data: customers, isLoading } = useResellerCustomers(activeShopId);
14+
const {
15+
data: customers,
16+
isLoading,
17+
error,
18+
refetch,
19+
} = useResellerCustomers(activeShopId);
1520

1621
if (!activeShopId) {
1722
return <NoShop title={t("reseller.customers.title")} />;
@@ -50,6 +55,8 @@ export function ResellerCustomersPage() {
5055
<DataTable
5156
columns={columns}
5257
rows={customers ?? []}
58+
error={error}
59+
onRetry={refetch}
5360
cols="1.4fr 1fr .6fr 1fr"
5461
rowKey={(c) => c.user_id}
5562
empty={isLoading ? t("common.loading") : t("reseller.customers.empty")}

web/admin/src/routes/subscriptions/index.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ export function AdminSubscriptionsPage() {
2727
const { t } = useTranslation();
2828
const navigate = useNavigate();
2929
const [pagination, setPagination] = useState({ limit: 50, offset: 0 });
30-
const { data: subs, isLoading } = useAdminSubscriptions(pagination);
30+
const {
31+
data: subs,
32+
isLoading,
33+
error,
34+
refetch,
35+
} = useAdminSubscriptions(pagination);
3136

3237
const columns: Column<Subscription>[] = [
3338
{
@@ -88,6 +93,8 @@ export function AdminSubscriptionsPage() {
8893
<DataTable<Subscription>
8994
columns={columns}
9095
rows={isLoading ? [] : (subs ?? [])}
96+
error={error}
97+
onRetry={refetch}
9198
cols=".9fr 1.1fr 1.1fr 1fr 1.1fr 40px"
9299
rowKey={(s) => s.id}
93100
onRowClick={(s) =>

web/admin/src/routes/tenants/index.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ export function TenantsPage() {
3535
const navigate = useNavigate();
3636
const [showForm, setShowForm] = useState(false);
3737
const [pagination, setPagination] = useState({ limit: 50, offset: 0 });
38-
const { data: tenants, isLoading } = useAdminTenants(pagination);
38+
const {
39+
data: tenants,
40+
isLoading,
41+
error,
42+
refetch,
43+
} = useAdminTenants(pagination);
3944
const createTenant = useCreateTenant();
4045

4146
const {
@@ -165,6 +170,8 @@ export function TenantsPage() {
165170
<DataTable<Tenant>
166171
columns={columns}
167172
rows={isLoading ? [] : (tenants ?? [])}
173+
error={error}
174+
onRetry={refetch}
168175
cols="1.6fr 1fr .9fr 1fr 40px"
169176
rowKey={(tn) => tn.id}
170177
onRowClick={(tn) =>

web/admin/src/routes/users/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function UsersPage() {
2121
const [pagination, setPagination] = useState({ limit: 50, offset: 0 });
2222
const [roleFilter, setRoleFilter] = useState<RoleFilter>("all");
2323
const [search, setSearch] = useState("");
24-
const { data: users, isLoading } = useAdminUsers(pagination);
24+
const { data: users, isLoading, error, refetch } = useAdminUsers(pagination);
2525

2626
const filtered = useMemo(() => {
2727
const term = search.trim().toLowerCase();
@@ -108,6 +108,8 @@ export function UsersPage() {
108108
<DataTable<User>
109109
columns={columns}
110110
rows={isLoading ? [] : filtered}
111+
error={error}
112+
onRetry={refetch}
111113
cols="1.6fr .8fr .9fr 1fr 40px"
112114
rowKey={(u) => u.id}
113115
onRowClick={(u) => navigate({ to: "/users/$id", params: { id: u.id } })}

web/shared/api/errors.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { HTTPError } from "ky";
2+
3+
/** True when err is a ky HTTP error with the given status. */
4+
function hasStatus(err: unknown, status: number): boolean {
5+
return err instanceof HTTPError && err.response.status === status;
6+
}
7+
8+
/** A 403 — the caller is authenticated but lacks permission for the resource. */
9+
export function isForbidden(err: unknown): boolean {
10+
return hasStatus(err, 403);
11+
}
12+
13+
/** A 401 — the caller is not (or no longer) authenticated. */
14+
export function isUnauthorized(err: unknown): boolean {
15+
return hasStatus(err, 401);
16+
}
17+
18+
/** Either an auth (401) or authorization (403) failure. */
19+
export function isAuthError(err: unknown): boolean {
20+
return isUnauthorized(err) || isForbidden(err);
21+
}

web/shared/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export {
1212
apiPutVoid,
1313
} from "./api/client.js";
1414
export { ENDPOINTS } from "./api/endpoints.js";
15+
export * from "./api/errors.js";
1516
export * from "./api/hooks/useAdmin.js";
1617
// Hooks - API
1718
export * from "./api/hooks/useAuth.js";

web/shared/lib/i18n.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ const en = {
66
loading: "Loading...",
77
error: "Something went wrong",
88
retry: "Retry",
9+
loadFailed: "Failed to load",
10+
accessDenied: "Access denied — you don't have permission to view this",
911
save: "Save",
1012
cancel: "Cancel",
1113
delete: "Delete",
@@ -376,6 +378,8 @@ const ru: typeof en = {
376378
loading: "Загрузка...",
377379
error: "Что-то пошло не так",
378380
retry: "Повторить",
381+
loadFailed: "Не удалось загрузить",
382+
accessDenied: "Доступ запрещён — у вас нет прав на просмотр",
379383
save: "Сохранить",
380384
cancel: "Отмена",
381385
delete: "Удалить",

0 commit comments

Comments
 (0)