diff --git a/resources/js/components/ManagedUserForm.vue b/resources/js/components/ManagedUserForm.vue new file mode 100644 index 0000000..f60ebb2 --- /dev/null +++ b/resources/js/components/ManagedUserForm.vue @@ -0,0 +1,369 @@ + + + diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index f0bcc11..1dfd0a7 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -154,6 +154,81 @@ export const en = { 'common.back': 'Back', 'common.done': 'Done', + 'adminUsers.eyebrow': 'Workspace administration', + 'adminUsers.title': 'User management', + 'adminUsers.description': + 'Manage workspace accounts, password requirements, and the global permissions that apply across KanbanGit.', + 'adminUsers.managedAccount': 'Managed account', + 'adminUsers.createAction': 'Create user', + 'adminUsers.updated': 'User management updated', + 'adminUsers.summary.label': 'User management summary', + 'adminUsers.summary.total': 'Workspace users', + 'adminUsers.summary.admins': 'Global administrators', + 'adminUsers.summary.passwordChanges': 'Password changes pending', + 'adminUsers.listTitle': 'Users ({count})', + 'adminUsers.listDescription': + 'Review account status, edit profile details, or confirm global access changes.', + 'adminUsers.empty.title': 'No users yet', + 'adminUsers.empty.description': + 'Create the first managed account for this workspace.', + 'adminUsers.passwordChangePending': 'Password change required', + 'adminUsers.permissions.none': 'No direct global permissions', + 'adminUsers.permissions.action': 'Manage access', + 'adminUsers.permissions.available': 'Available global permissions', + 'adminUsers.permissions.globalAdmin': 'Global administrator', + 'adminUsers.permissions.globalAdminHelp': + 'Allows access to workspace-wide administration, including this user management area.', + 'adminUsers.permissions.projectCreate': 'Create projects', + 'adminUsers.permissions.projectCreateHelp': + 'Allows the user to create new projects without granting global administration.', + 'adminUsers.permissions.title': 'Global access for {name}', + 'adminUsers.permissions.description': + 'Select the direct permissions this account should receive.', + 'adminUsers.permissions.warning': + 'These changes take effect across the workspace after confirmation.', + 'adminUsers.permissions.confirm': 'Confirm access changes', + 'adminUsers.create.title': 'Create user', + 'adminUsers.create.description': + 'Add a workspace account with an initial password and a clear first-login requirement.', + 'adminUsers.edit.breadcrumb': 'Edit {name}', + 'adminUsers.edit.pageTitle': 'Edit {name}', + 'adminUsers.edit.title': 'Edit {name}', + 'adminUsers.edit.action': 'Edit account details for {name}', + 'adminUsers.edit.shortAction': 'Edit details', + 'adminUsers.form.detailsTitle': 'Account details', + 'adminUsers.form.createDescription': + 'Set the identity and initial security state for the new account.', + 'adminUsers.form.editDescription': + 'Update account details and review security-sensitive changes before saving.', + 'adminUsers.form.name': 'Name', + 'adminUsers.form.namePlaceholder': 'Jane Admin', + 'adminUsers.form.email': 'Email address', + 'adminUsers.form.emailPlaceholder': 'jane@example.com', + 'adminUsers.form.passwordTitle': 'Password and first sign-in', + 'adminUsers.form.passwordCreateHelp': + 'Set a temporary password that the user can replace after signing in.', + 'adminUsers.form.passwordEditHelp': + 'Leave both password fields empty to keep the current password.', + 'adminUsers.form.password': 'Temporary password', + 'adminUsers.form.newPassword': 'New password', + 'adminUsers.form.passwordPlaceholder': 'Enter a temporary password', + 'adminUsers.form.passwordEditPlaceholder': + 'Leave blank to keep the current password', + 'adminUsers.form.confirmPassword': 'Confirm password', + 'adminUsers.form.confirmPasswordPlaceholder': 'Repeat the new password', + 'adminUsers.form.forcePasswordChange': + 'Require a password change on next sign-in', + 'adminUsers.form.forcePasswordChangeHelp': + 'The user stays in security settings until a new password has been saved.', + 'adminUsers.form.createAction': 'Create user', + 'adminUsers.form.reviewChanges': 'Review changes', + 'adminUsers.form.confirmTitle': 'Update {name}?', + 'adminUsers.form.confirmDescription': + 'Confirm the account and security changes before they are applied.', + 'adminUsers.form.confirmWarning': + 'A new password takes effect immediately. Requiring a password change restricts the user to security settings on their next sign-in.', + 'adminUsers.form.confirmAction': 'Confirm and save', + 'criteria.title': 'Acceptance criteria', 'criteria.progress': '{completed} of {total} complete', 'criteria.empty': 'No acceptance criteria yet.', diff --git a/resources/js/pages/admin/users/Create.vue b/resources/js/pages/admin/users/Create.vue index c21acf0..2a533a2 100644 --- a/resources/js/pages/admin/users/Create.vue +++ b/resources/js/pages/admin/users/Create.vue @@ -1,165 +1,39 @@ diff --git a/resources/js/pages/admin/users/Edit.vue b/resources/js/pages/admin/users/Edit.vue index 7619318..36754f2 100644 --- a/resources/js/pages/admin/users/Edit.vue +++ b/resources/js/pages/admin/users/Edit.vue @@ -1,170 +1,60 @@ diff --git a/resources/js/pages/admin/users/Index.vue b/resources/js/pages/admin/users/Index.vue index 754e0ae..6f8d820 100644 --- a/resources/js/pages/admin/users/Index.vue +++ b/resources/js/pages/admin/users/Index.vue @@ -1,267 +1,468 @@ diff --git a/tests/Feature/Admin/UserManagementUiTest.php b/tests/Feature/Admin/UserManagementUiTest.php new file mode 100644 index 0000000..7ba392b --- /dev/null +++ b/tests/Feature/Admin/UserManagementUiTest.php @@ -0,0 +1,25 @@ +setTimeout(60); + $process->run(); + + $this->assertTrue( + $process->isSuccessful(), + $process->getOutput().$process->getErrorOutput(), + ); + $this->assertStringContainsString( + 'User management rendering checks passed.', + $process->getOutput(), + ); + } +} diff --git a/tests/Fixtures/user-management-preview.mjs b/tests/Fixtures/user-management-preview.mjs new file mode 100644 index 0000000..31ba643 --- /dev/null +++ b/tests/Fixtures/user-management-preview.mjs @@ -0,0 +1,21 @@ +import { createApp, h } from 'vue'; +import { createInertiaApp } from '@inertiajs/vue3'; +import { createAppearance } from '@/composables/useAppearance'; +import { createLocalization } from '@/lib/localization'; +import Create from '@/pages/admin/users/Create.vue'; +import Edit from '@/pages/admin/users/Edit.vue'; +import Index from '@/pages/admin/users/Index.vue'; +import '@/../css/app.css'; + +const components = { Create, Edit, Index }; + +createInertiaApp({ + resolve: (name) => components[name], + setup({ el, App, props, plugin }) { + createApp({ render: () => h(App, props) }) + .use(plugin) + .use(createLocalization()) + .use(createAppearance()) + .mount(el); + }, +}); diff --git a/tests/Fixtures/user-management.mjs b/tests/Fixtures/user-management.mjs new file mode 100644 index 0000000..5073685 --- /dev/null +++ b/tests/Fixtures/user-management.mjs @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import vue from '@vitejs/plugin-vue'; +import tailwind from '@tailwindcss/vite'; +import { createServer } from 'vite'; +import { createSSRApp, h } from 'vue'; +import { renderToString } from 'vue/server-renderer'; +import { createInertiaApp } from '@inertiajs/vue3'; + +const serve = process.argv.includes('--serve'); +const server = await createServer({ + configFile: false, + cacheDir: 'node_modules/.vite/user-management-fixture', + plugins: [vue(), ...(serve ? [tailwind()] : [])], + resolve: { + alias: { + '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), + }, + }, + server: serve + ? { host: '127.0.0.1', port: 4191, strictPort: true, hmr: false } + : { middlewareMode: true, hmr: false, watch: null }, + appType: 'custom', +}); + +const admin = { + id: 1, + name: 'Alex Admin', + email: 'alex@example.test', + avatar: null, + avatar_color: '#10B981', + avatar_is_generated: true, + email_verified_at: null, + must_change_password_on_next_login: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', +}; +const member = { + ...admin, + id: 2, + name: 'Morgan Member', + email: 'morgan@example.test', + must_change_password_on_next_login: true, +}; +const shared = { + name: 'KanbanGit', + errors: {}, + sidebarOpen: true, + appearance: 'system', + auth: { + user: admin, + permissions: { + globalAdmin: true, + projectCreate: true, + canManageTeams: true, + }, + }, + navigation: { project: null }, +}; +const pages = { + index: { + component: 'Index', + url: '/admin/users', + props: { + ...shared, + directPermissions: { + 1: ['global:admin', 'project:create'], + 2: [], + }, + permissionOptions: [ + { value: 'global:admin', label: 'Global admin' }, + { value: 'project:create', label: 'Create project' }, + ], + users: [admin, member], + status: null, + }, + }, + create: { + component: 'Create', + url: '/admin/users/create', + props: shared, + }, + edit: { + component: 'Edit', + url: '/admin/users/2/edit', + props: { ...shared, user: member }, + }, +}; + +function inertiaPage(page) { + return { + ...page, + version: null, + clearHistory: false, + encryptHistory: false, + }; +} + +async function render(path, page) { + const component = (await server.ssrLoadModule(path)).default; + const { createLocalization } = await server.ssrLoadModule( + '/resources/js/lib/localization.ts', + ); + const { createAppearance } = await server.ssrLoadModule( + '/resources/js/composables/useAppearance.ts', + ); + + return ( + await createInertiaApp({ + page: inertiaPage(page), + resolve: () => component, + render: renderToString, + setup: ({ App, props, plugin }) => + createSSRApp({ render: () => h(App, props) }) + .use(plugin) + .use(createLocalization()) + .use(createAppearance()), + }) + ).body; +} + +if (serve) { + server.middlewares.use(async (request, response, next) => { + if (!request.url.startsWith('/admin/users')) { + return next(); + } + + const pathname = new URL(request.url, 'http://localhost').pathname; + const page = pathname.endsWith('/create') + ? pages.create + : pathname.endsWith('/edit') + ? pages.edit + : pages.index; + const completePage = inertiaPage(page); + response.setHeader('Vary', 'X-Inertia'); + + if (request.headers['x-inertia']) { + response.setHeader('X-Inertia', 'true'); + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(completePage)); + return; + } + + const escaped = JSON.stringify(completePage) + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<'); + response.setHeader('Content-Type', 'text/html'); + response.end( + await server.transformIndexHtml( + request.url, + '
', + ), + ); + }); + await server.listen(); + console.log('User management fixture ready.'); +} else { + try { + const indexHtml = await render( + '/resources/js/pages/admin/users/Index.vue', + pages.index, + ); + assert.match(indexHtml, /Workspace administration/); + assert.match(indexHtml, /User management/); + assert.match(indexHtml, /Workspace users/); + assert.match(indexHtml, /Global administrators/); + assert.match(indexHtml, /Password changes pending/); + assert.equal(indexHtml.match(/data-managed-user/g)?.length, 2); + assert.match(indexHtml, /Alex Admin/); + assert.match(indexHtml, /Morgan Member/); + assert.match(indexHtml, /Manage access/); + assert.match(indexHtml, /Edit account details for Morgan Member/); + assert.match(indexHtml, /Password change required/); + assert.match(indexHtml, /No direct global permissions/); + + const createHtml = await render( + '/resources/js/pages/admin/users/Create.vue', + pages.create, + ); + assert.match(createHtml, /Create user/); + assert.match(createHtml, /Temporary password/); + assert.match(createHtml, /Confirm password/); + assert.match(createHtml, /Require a password change on next sign-in/); + assert.match(createHtml, /data-test="managed-user-submit"/); + assert.match(createHtml, /managed-user-password-confirmation-error/); + + const editHtml = await render( + '/resources/js/pages/admin/users/Edit.vue', + pages.edit, + ); + assert.match(editHtml, /Edit Morgan Member/); + assert.match(editHtml, /morgan@example\.test/); + assert.match(editHtml, /Leave both password fields empty/); + assert.match(editHtml, /Review changes/); + assert.match(editHtml, /value="Morgan Member"/); + assert.match(editHtml, /value="morgan@example\.test"/); + + console.log('User management rendering checks passed.'); + } finally { + await server.close(); + } +}