{t('nav.platform')}
@@ -57,7 +93,15 @@ export default function DashboardLayout() {
+
+ )}
+ {isSystemAdmin && (
+
+
)}
@@ -66,62 +110,89 @@ export default function DashboardLayout() {
{isSystemAdmin && (
{t('nav.schemaManagement')}
-
-
)}
{t('nav.dataExplorer')}
-
- {schemas.map((schema) => (
-
-
-
- {schema}
-
-
- ))}
- {schemas.length === 0 && (
-
{t('nav.noSchemas')}
- )}
-
+
+
+
+ {t('nav.dataExplorer')}
+
+
+
+ {/* Recent Explore List */}
+ {recentExplore.length > 0 && (
+
+
{t('nav.recent')}
+ {recentExplore.map((schema) => (
+
+
+
+ {schema}
+
+
+ ))}
+
+ )}
- {/* Language Switcher and Logout */}
+ {/* User Profile, Language Switcher and Logout */}
-
-
-
+ {user && (
+
+
+ {user.email.charAt(0).toUpperCase()}
+
+
+
+ {user.email}
+
+
+ {user.role.replace('_', ' ')}
+
+
+
+ )}
+
+
+
+
+
+
+
+
-
-
- Logout
-
@@ -132,7 +203,7 @@ export default function DashboardLayout() {
{t('app.title')}
-
+
diff --git a/dynaman-ui/src/components/RelativeTime.tsx b/dynaman-ui/src/components/RelativeTime.tsx
new file mode 100644
index 0000000..a26dba7
--- /dev/null
+++ b/dynaman-ui/src/components/RelativeTime.tsx
@@ -0,0 +1,57 @@
+import { useMemo } from 'react';
+import { useLanguage } from '@/lib/i18n';
+
+interface RelativeTimeProps {
+ date: string | Date | undefined | null;
+}
+
+export function RelativeTime({ date }: RelativeTimeProps) {
+ const { language } = useLanguage();
+
+ const { relative, absolute } = useMemo(() => {
+ if (!date) return { relative: '-', absolute: '' };
+
+ const dateObj = new Date(date);
+ if (isNaN(dateObj.getTime())) return { relative: '-', absolute: '' };
+
+ const now = new Date();
+ const diffInSeconds = Math.floor((now.getTime() - dateObj.getTime()) / 1000);
+
+ // Absolute format for tooltip
+ const absoluteFormatter = new Intl.DateTimeFormat(language, {
+ dateStyle: 'full',
+ timeStyle: 'medium',
+ });
+ const absoluteStr = absoluteFormatter.format(dateObj);
+
+ // Relative format
+ const rtf = new Intl.RelativeTimeFormat(language, { numeric: 'auto' });
+
+ let relativeStr = '';
+ if (diffInSeconds < 60) {
+ relativeStr = rtf.format(-diffInSeconds, 'second');
+ } else if (diffInSeconds < 3600) {
+ relativeStr = rtf.format(-Math.floor(diffInSeconds / 60), 'minute');
+ } else if (diffInSeconds < 86400) {
+ relativeStr = rtf.format(-Math.floor(diffInSeconds / 3600), 'hour');
+ } else if (diffInSeconds < 604800) { // 7 days
+ relativeStr = rtf.format(-Math.floor(diffInSeconds / 86400), 'day');
+ } else if (diffInSeconds < 2592000) { // 30 days
+ relativeStr = rtf.format(-Math.floor(diffInSeconds / 604800), 'week');
+ } else if (diffInSeconds < 31536000) { // 365 days
+ relativeStr = rtf.format(-Math.floor(diffInSeconds / 2592000), 'month');
+ } else {
+ relativeStr = rtf.format(-Math.floor(diffInSeconds / 31536000), 'year');
+ }
+
+ return { relative: relativeStr, absolute: absoluteStr };
+ }, [date, language]);
+
+ if (!date) return
-;
+
+ return (
+
+ {relative}
+
+ );
+}
diff --git a/dynaman-ui/src/context/AuthContext.tsx b/dynaman-ui/src/context/AuthContext.tsx
index f7980d9..5e146c0 100644
--- a/dynaman-ui/src/context/AuthContext.tsx
+++ b/dynaman-ui/src/context/AuthContext.tsx
@@ -6,6 +6,7 @@ export interface User {
email: string;
role: 'system_admin' | 'user_admin' | 'user';
is_active: boolean;
+ group_ids?: string[];
}
interface AuthContextType {
diff --git a/dynaman-ui/src/lib/api.ts b/dynaman-ui/src/lib/api.ts
index 7c703a6..5854280 100644
--- a/dynaman-ui/src/lib/api.ts
+++ b/dynaman-ui/src/lib/api.ts
@@ -34,4 +34,87 @@ api.interceptors.response.use(
}
);
+export interface UserGroup {
+ _id: string;
+ name: string;
+ description?: string;
+}
+
+export interface User {
+ _id: string;
+ email: string;
+ role: string;
+ is_active: boolean;
+ group_ids?: string[];
+}
+
+export const groupApi = {
+ list: async () => {
+ const response = await api.get
('/api/v1/groups');
+ return response.data;
+ },
+ create: async (data: { name: string; description?: string }) => {
+ const response = await api.post('/api/v1/groups', data);
+ return response.data;
+ },
+ get: async (id: string) => {
+ const response = await api.get(`/api/v1/groups/${id}`);
+ return response.data;
+ },
+ update: async (id: string, data: { name?: string; description?: string }) => {
+ const response = await api.put(`/api/v1/groups/${id}`, data);
+ return response.data;
+ },
+ delete: async (id: string) => {
+ await api.delete(`/api/v1/groups/${id}`);
+ },
+};
+
+export const userApi = {
+ list: async () => {
+ const response = await api.get('/api/v1/auth/users');
+ return response.data;
+ },
+ update: async (id: string, data: Partial) => {
+ const response = await api.put(`/api/v1/auth/users/${id}`, data);
+ return response.data;
+ }
+};
+
+export interface FormLayout {
+ _id: string;
+ schema_name: string;
+ name: string;
+ definition: any[]; // JSON structure
+ target_group_ids: string[];
+ is_default: boolean;
+}
+
+export const layoutApi = {
+ resolve: async (schemaName: string) => {
+ try {
+ const response = await api.get(`/api/v1/layouts/resolve/${schemaName}`);
+ return response.data;
+ } catch (error) {
+ console.error("Failed to resolve layout", error);
+ return null;
+ }
+ },
+ create: async (data: Omit) => {
+ const response = await api.post('/api/v1/layouts/', data);
+ return response.data;
+ },
+ listBySchema: async (schemaName: string) => {
+ const response = await api.get(`/api/v1/layouts/by-schema/${schemaName}`);
+ return response.data;
+ },
+ update: async (id: string, data: Partial) => {
+ const response = await api.put(`/api/v1/layouts/${id}`, data);
+ return response.data;
+ },
+ delete: async (id: string) => {
+ await api.delete(`/api/v1/layouts/${id}`);
+ }
+};
+
export default api;
diff --git a/dynaman-ui/src/lib/i18n.tsx b/dynaman-ui/src/lib/i18n.tsx
index 642a6bd..030d9e3 100644
--- a/dynaman-ui/src/lib/i18n.tsx
+++ b/dynaman-ui/src/lib/i18n.tsx
@@ -13,18 +13,22 @@ const translations = {
'nav.createNew': 'Create New',
'nav.dataExplorer': 'Data Explorer',
'nav.noSchemas': 'No schemas found.',
+ 'nav.userManagement': 'User Management',
+ 'nav.userGroups': 'User Groups',
+ 'nav.recent': 'Recent',
+ 'nav.logout': 'Logout',
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.edit': 'Edit',
- 'common.loading': 'Loading...',
- 'common.error': 'Error',
- 'common.search': 'Search...',
- 'common.refresh': 'Refresh',
- 'common.previous': 'Previous',
- 'common.next': 'Next',
- 'common.page': 'Page {page}',
- 'schema.editor.title.create': 'Create New Schema', 'schema.editor.title.edit': 'Edit Schema: {entity}',
+ 'common.loading': 'Loading...',
+ 'common.error': 'Error',
+ 'common.search': 'Search...',
+ 'common.refresh': 'Refresh',
+ 'common.previous': 'Previous',
+ 'common.next': 'Next',
+ 'common.page': 'Page {page}',
+ 'schema.editor.title.create': 'Create New Schema', 'schema.editor.title.edit': 'Edit Schema: {entity}',
'schema.editor.delete': 'Delete Schema',
'schema.editor.name': 'Schema Name',
'schema.editor.fields': 'Fields',
@@ -46,6 +50,13 @@ const translations = {
'schema.editor.save': 'Save Schema',
'schema.editor.saving': 'Saving...',
'schema.editor.confirmDelete': 'Are you sure you want to delete schema \'{entity}\'? This action cannot be undone.',
+ 'schema.list.manageDescription': 'Manage your data schemas, edit fields, and configure layouts.',
+ 'schema.list.exploreDescription': 'Explore and manage data entries for your schemas.',
+ 'schema.list.createSchema': 'Create Schema',
+ 'schema.list.editDefinition': 'Edit schema definition',
+ 'schema.list.viewData': 'View and manage data',
+ 'schema.list.getStarted': 'Get started by creating your first schema.',
+ 'schema.list.startExploring': 'Create a schema to start exploring data.',
'explorer.title': 'Data Explorer: {entity}',
'explorer.addData': 'Add Data',
'explorer.noData': 'No data found.',
@@ -64,7 +75,7 @@ const translations = {
'form.validation.json': 'Invalid JSON for {label}',
'common.searchEntity': 'Search {entity}...',
'common.selectEntity': 'Select a {entity}',
- 'common.loadingOptions': 'Loading options...',
+ 'common.loadingOptions': 'Loading options...',
'common.noEntityResults': 'No results found. Create new {entity} records first.',
'common.loadError': 'Failed to load {entity} options.',
'home.welcome': 'Welcome to Dynaman',
@@ -75,6 +86,19 @@ const translations = {
'home.explorer.description': 'View, search, and edit records for any entity.',
'home.api.title': 'API Ready',
'home.api.description': 'All data is instantly available via the execution API.',
+ 'userGroups.title': 'User Groups',
+ 'userGroups.createGroup': 'Create Group',
+ 'userGroups.cancel': 'Cancel',
+ 'userGroups.name': 'Group Name',
+ 'userGroups.description': 'Description',
+ 'userGroups.table.name': 'Name',
+ 'userGroups.table.description': 'Description',
+ 'userGroups.table.actions': 'Actions',
+ 'userGroups.noGroups': 'No groups found.',
+ 'userGroups.confirmDelete': 'Are you sure you want to delete this group?',
+ 'userGroups.createError': 'Failed to create group',
+ 'userGroups.deleteError': 'Failed to delete group',
+ 'userGroups.loadError': 'Failed to load groups',
},
ja: {
'app.title': 'Dynaman',
@@ -84,18 +108,22 @@ const translations = {
'nav.createNew': '新規作成',
'nav.dataExplorer': 'データエクスプローラー',
'nav.noSchemas': 'スキーマが見つかりません。',
+ 'nav.userManagement': 'ユーザー管理',
+ 'nav.userGroups': 'ユーザーグループ',
+ 'nav.recent': '最近の項目',
+ 'nav.logout': 'ログアウト',
'common.save': '保存',
'common.cancel': 'キャンセル',
'common.delete': '削除',
'common.edit': '編集',
- 'common.loading': '読み込み中...',
- 'common.error': 'エラー',
- 'common.search': '検索...',
- 'common.refresh': '更新',
- 'common.previous': '前へ',
- 'common.next': '次へ',
- 'common.page': 'ページ {page}',
- 'schema.editor.title.create': '新しいスキーマを作成', 'schema.editor.title.edit': 'スキーマ編集: {entity}',
+ 'common.loading': '読み込み中...',
+ 'common.error': 'エラー',
+ 'common.search': '検索...',
+ 'common.refresh': '更新',
+ 'common.previous': '前へ',
+ 'common.next': '次へ',
+ 'common.page': 'ページ {page}',
+ 'schema.editor.title.create': '新しいスキーマを作成', 'schema.editor.title.edit': 'スキーマ編集: {entity}',
'schema.editor.delete': 'スキーマを削除',
'schema.editor.name': 'スキーマ名',
'schema.editor.fields': 'フィールド',
@@ -117,6 +145,13 @@ const translations = {
'schema.editor.save': 'スキーマを保存',
'schema.editor.saving': '保存中...',
'schema.editor.confirmDelete': '本当にスキーマ \'{entity}\' を削除してもよろしいですか?この操作は取り消せません。',
+ 'schema.list.manageDescription': 'データスキーマの管理、フィールドの編集、レイアウトの設定を行います。',
+ 'schema.list.exploreDescription': 'スキーマのデータエントリを探索および管理します。',
+ 'schema.list.createSchema': 'スキーマを作成',
+ 'schema.list.editDefinition': 'スキーマ定義を編集',
+ 'schema.list.viewData': 'データを表示および管理',
+ 'schema.list.getStarted': '最初のスキーマを作成して始めましょう。',
+ 'schema.list.startExploring': 'データを探索するためにスキーマを作成してください。',
'explorer.title': 'データエクスプローラー: {entity}',
'explorer.addData': 'データを追加',
'explorer.noData': 'データが見つかりません。',
@@ -133,9 +168,9 @@ const translations = {
'form.validation.required': 'フィールド \'{label}\' は必須です。',
'form.validation.number': 'フィールド \'{label}\' は数値である必要があります。',
'form.validation.json': '{label} のJSONが無効です',
- 'common.searchEntity': '{entity} を検索...',
+ 'common.searchEntity': '{entity} を検索...',
'common.selectEntity': '{entity} を選択',
- 'common.loadingOptions': 'オプションを読み込み中...',
+ 'common.loadingOptions': 'オプションを読み込み中...',
'common.noEntityResults': '結果が見つかりません。先に新しい {entity} レコードを作成してください。',
'common.loadError': '{entity} オプションの読み込みに失敗しました。',
'home.welcome': 'Dynaman へようこそ',
@@ -146,6 +181,19 @@ const translations = {
'home.explorer.description': '任意のエンティティのレコードを表示、検索、編集します。',
'home.api.title': 'API 対応',
'home.api.description': 'すべてのデータは実行 API を介してすぐに利用可能です。',
+ 'userGroups.title': 'ユーザーグループ',
+ 'userGroups.createGroup': 'グループ作成',
+ 'userGroups.cancel': 'キャンセル',
+ 'userGroups.name': 'グループ名',
+ 'userGroups.description': '説明',
+ 'userGroups.table.name': '名前',
+ 'userGroups.table.description': '説明',
+ 'userGroups.table.actions': 'アクション',
+ 'userGroups.noGroups': 'グループが見つかりません。',
+ 'userGroups.confirmDelete': '本当にこのグループを削除してもよろしいですか?',
+ 'userGroups.createError': 'グループの作成に失敗しました',
+ 'userGroups.deleteError': 'グループの削除に失敗しました',
+ 'userGroups.loadError': 'グループの読み込みに失敗しました',
}
};
diff --git a/dynaman-ui/src/pages/AdminGroups.tsx b/dynaman-ui/src/pages/AdminGroups.tsx
new file mode 100644
index 0000000..64d10f3
--- /dev/null
+++ b/dynaman-ui/src/pages/AdminGroups.tsx
@@ -0,0 +1,142 @@
+import { useState, useEffect } from 'react';
+import { groupApi, type UserGroup } from '../lib/api';
+import { Button } from '../components/ui/button';
+import { Input } from '../components/ui/input';
+import { Label } from '../components/ui/label';
+import { useLanguage } from '@/lib/i18n';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '../components/ui/table';
+
+export default function AdminGroups() {
+ const [groups, setGroups] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const { t } = useLanguage();
+
+ // Form State
+ const [isCreating, setIsCreating] = useState(false);
+ const [newName, setNewName] = useState('');
+ const [newDesc, setNewDesc] = useState('');
+
+ const fetchGroups = async () => {
+ try {
+ setLoading(true);
+ const data = await groupApi.list();
+ setGroups(data);
+ } catch (err) {
+ setError(t('userGroups.loadError'));
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchGroups();
+ }, []);
+
+ const handleCreate = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ await groupApi.create({ name: newName, description: newDesc });
+ setNewName('');
+ setNewDesc('');
+ setIsCreating(false);
+ fetchGroups();
+ } catch (err) {
+ alert(t('userGroups.createError'));
+ console.error(err);
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ if (!confirm(t('userGroups.confirmDelete'))) return;
+ try {
+ await groupApi.delete(id);
+ fetchGroups();
+ } catch (err) {
+ alert(t('userGroups.deleteError'));
+ console.error(err);
+ }
+ };
+
+ if (loading) return {t('common.loading')}
;
+ if (error) return {error}
;
+
+ return (
+
+
+
{t('userGroups.title')}
+ setIsCreating(!isCreating)}>
+ {isCreating ? t('userGroups.cancel') : t('userGroups.createGroup')}
+
+
+
+ {isCreating && (
+
+ )}
+
+
+
+
+ {t('userGroups.table.name')}
+ {t('userGroups.table.description')}
+ {t('userGroups.table.actions')}
+
+
+
+ {Array.isArray(groups) && groups.length > 0 ? (
+ groups.map((group) => (
+
+ {group.name}
+ {group.description}
+
+ handleDelete(group._id)}
+ >
+ {t('common.delete')}
+
+
+
+ ))
+ ) : (
+
+
+ {t('userGroups.noGroups')}
+
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/dynaman-ui/src/pages/AdminUsers.tsx b/dynaman-ui/src/pages/AdminUsers.tsx
index 4b3772a..f84538c 100644
--- a/dynaman-ui/src/pages/AdminUsers.tsx
+++ b/dynaman-ui/src/pages/AdminUsers.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
-import api from '@/lib/api';
+import api, { groupApi, type UserGroup } from '@/lib/api';
import { useAuth, type User } from '@/context/AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -16,6 +16,7 @@ import { Trash2, UserPlus } from 'lucide-react';
export const AdminUsers: React.FC = () => {
const [users, setUsers] = useState([]);
+ const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
const { user: currentUser } = useAuth();
@@ -24,11 +25,12 @@ export const AdminUsers: React.FC = () => {
const [newUserEmail, setNewUserEmail] = useState('');
const [newUserPassword, setNewUserPassword] = useState('');
const [newUserRole, setNewUserRole] = useState('user');
+ const [selectedGroups, setSelectedGroups] = useState([]);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
useEffect(() => {
- fetchUsers();
+ Promise.all([fetchUsers(), fetchGroups()]);
}, []);
const fetchUsers = async () => {
@@ -42,6 +44,15 @@ export const AdminUsers: React.FC = () => {
}
};
+ const fetchGroups = async () => {
+ try {
+ const data = await groupApi.list();
+ setGroups(data);
+ } catch (err) {
+ console.error('Failed to fetch groups', err);
+ }
+ };
+
const handleDelete = async (userId: string) => {
if (!window.confirm("Are you sure you want to delete this user?")) return;
try {
@@ -62,7 +73,8 @@ export const AdminUsers: React.FC = () => {
const payload = {
email: newUserEmail,
password: newUserPassword,
- role: newUserRole
+ role: newUserRole,
+ group_ids: selectedGroups
};
const response = await api.post('/api/v1/auth/users', payload);
setUsers([...users, response.data]);
@@ -71,6 +83,7 @@ export const AdminUsers: React.FC = () => {
setNewUserEmail('');
setNewUserPassword('');
setNewUserRole('user');
+ setSelectedGroups([]);
} catch (err: any) {
if (err.response?.data?.detail) {
setError(err.response.data.detail);
@@ -80,6 +93,14 @@ export const AdminUsers: React.FC = () => {
}
};
+ const toggleGroup = (groupId: string) => {
+ setSelectedGroups(prev =>
+ prev.includes(groupId)
+ ? prev.filter(id => id !== groupId)
+ : [...prev, groupId]
+ );
+ };
+
if (loading) return Loading users...
;
return (
@@ -117,6 +138,28 @@ export const AdminUsers: React.FC = () => {
+