diff --git a/.github/workflows/build.yml b/.github-upstream/workflows/build.yml similarity index 100% rename from .github/workflows/build.yml rename to .github-upstream/workflows/build.yml diff --git a/README.md b/README.md index de00963..9f19661 100644 --- a/README.md +++ b/README.md @@ -1,158 +1,55 @@

- - - + inbuxa

-

- Web-based User Interface for Stalwart 🛡️ -

+

INBUXA Admin

-
+The administration interface for the INBUXA mail server: every server setting, +first-boot setup, and recovery, in the browser. -

- continuous integration -   - License: AGPL v3 -   - Documentation -

-

- Mastodon -   - Twitter -

-

- Discord -   - Matrix -

+It is a fork of [Stalwart WebUI](https://github.com/stalwartlabs/webui). Like +upstream it is schema-driven. After signing in it fetches the server's schema +and builds every form, list and menu from it, so it covers every setting the +server has without hardcoding any of them. -## Features +> **Status: in development, not released.** -**Stalwart WebUI** is schema-driven single-page application for administering [Stalwart](https://stalw.art). After authentication the panel fetches a JSON schema from the server and dynamically generates all forms, lists, navigation, and layouts from that schema. Nothing is hardcoded. +## What's different from upstream -Key features: +- **One edition.** Nothing is hidden or marked as Enterprise-only. INBUXA + ships every feature to everybody. See the INBUXA server's `docs/spec/`. +- **Runs anywhere, not on the mail server.** Upstream is installed onto the + mail server itself. INBUXA Admin is its own deployment, pointed at the server + either at build time (`VITE_API_BASE_URL`) or at deploy time: + `` in + `index.html`. +- **INBUXA's look:** the logo and ihasmail's palette. +- **Two-factor setup** names INBUXA as the issuer, and no longer makes + authenticator apps fetch a logo from a third-party site. -- **Schema-driven UI**: All forms, lists, and navigation are generated from a JSON schema fetched from `/api/schema` after login. No object types, field names, or layouts are hardcoded. -- **JMAP protocol**: All data operations (queries, creates, updates, deletes, blob uploads) use JMAP (RFC 8620) with method chaining and result references. -- **Permission-aware**: Every button, link, field, and section respects the user's permissions. Elements the user cannot access are hidden. +## Developing -## Screenshots - - - -## Get Started - -Stalwart WebUI is included with Stalwart Mail Server, to install Stalwart Mail Server on your server by following the instructions for your platform: - -- [Linux / MacOS](https://stalw.art/docs/install/linux) -- [Windows](https://stalw.art/docs/install/windows) -- [Docker](https://stalw.art/docs/install/docker) - -All documentation is available at [stalw.art/docs/get-started](https://stalw.art/docs/get-started). - -## Getting started - -Prerequisites: - -- Node.js 18 or later -- A running Stalwart instance (for JMAP API calls) - -Install dependencies: - -``` -npm install -``` - -### Environment variables - -Configuration is done through Vite environment variables. Copy or edit `.env.development` in the project root: - -``` -VITE_API_BASE_URL=http://localhost:443 -VITE_ACCESS_TOKEN= -VITE_OAUTH_SCOPES= -``` - -| Variable | Description | -|---|---| -| `VITE_API_BASE_URL` | URL of the Stalwart server. Used for all API requests during development. In production builds (when empty or unset) requests are relative to the current origin. | -| `VITE_ACCESS_TOKEN` | When set, skips the OAuth flow entirely and uses this token for all requests. Useful for local development and testing. | -| `VITE_OAUTH_SCOPES` | Optional OAuth scopes. Omitted from the authorization request when empty. | - -### OAuth client ID - -The OAuth 2.0 client ID is not a build-time setting. It is read at runtime from a meta tag in `index.html`: - -```html - -``` - -The server rewrites the `content` attribute when it serves the page, so a single build works for any deployment. When no -client ID is configured the attribute is left empty and the panel falls back to `stalwart-webui`. - -### Bypassing OAuth for development - -Set `VITE_ACCESS_TOKEN` to a valid bearer token to skip the login page and go straight to the admin panel. You can obtain a token from the Stalwart server's token endpoint or use an API key: - -``` -VITE_ACCESS_TOKEN=your-bearer-token-here -``` - -### Running the dev server - -``` -npm run dev -``` - -This starts Vite's development server with hot module replacement, typically at `http://localhost:5173`. - -## Testing - -Run the unit tests (Vitest): - -``` -npm test -``` - -Run tests in watch mode: - -``` -npm run test:watch -``` - -## Building for production - -``` +```bash +npm ci +npm run dev # http://localhost:5173, against VITE_API_BASE_URL in .env.development +npm run typecheck && npx eslint src/ && npx vitest run npm run build ``` -This runs the TypeScript compiler followed by Vite's production build. Output -goes to the `dist/` directory. +## Keeping up with upstream -To preview the production build locally: +Upstream's history contains no Enterprise-only code, so this is an ordinary +git fork. `upstream` is a fetch-only remote: -``` -npm run preview +```bash +git fetch upstream --tags +git merge v1.0.12 # the next release tag ``` -## Support +## License and credits -If you are having problems running Stalwart Mail Server, you found a bug or just have a question, -do not hesitate to reach us on [Github Discussions](https://github.com/stalwartlabs/mail-server/discussions), -[Reddit](https://www.reddit.com/r/stalwartlabs), [Discord](https://discord.gg/aVQr3jF8jd) or [Matrix](https://matrix.to/#/#stalwart:matrix.org). -Additionally you may purchase a subscription to obtain priority support from Stalwart Labs LLC - -## License - -This project is dual-licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0; as published by the Free Software Foundation) and the **Stalwart Enterprise License v1 (SELv1)**: - -- The [GNU Affero General Public License v3.0](./LICENSES/AGPL-3.0-only.txt) is a free software license that ensures your freedom to use, modify, and distribute the software, with the condition that any modified versions of the software must also be distributed under the same license. -- The [Stalwart Enterprise License v1 (SELv1)](./LICENSES/LicenseRef-SEL.txt) is a proprietary license designed for commercial use. It offers additional features and greater flexibility for businesses that do not wish to comply with the AGPL-3.0 license requirements. - -Each file in this project contains a license notice at the top, indicating the applicable license(s). The license notice follows the [REUSE guidelines](https://reuse.software/) to ensure clarity and consistency. The full text of each license is available in the [LICENSES](./LICENSES/) directory. - -## Copyright - -Copyright (C) 2024, Stalwart Labs LLC +Free software under the [GNU Affero General Public License, version 3](./LICENSES/AGPL-3.0-only.txt). +A fork of Stalwart WebUI, copyright © Stalwart Labs LLC. Upstream's files are +dual-licensed AGPL-3.0-only or Stalwart's Enterprise License, and INBUXA takes +them under the AGPL-3.0 only. Upstream's copyright notices are kept on every +file. INBUXA isn't affiliated with or endorsed by Stalwart Labs. diff --git a/img/brand/inbuxa-lockup-dark.svg b/img/brand/inbuxa-lockup-dark.svg new file mode 100644 index 0000000..40117f7 --- /dev/null +++ b/img/brand/inbuxa-lockup-dark.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/img/brand/inbuxa-lockup-light.svg b/img/brand/inbuxa-lockup-light.svg new file mode 100644 index 0000000..f9f4940 --- /dev/null +++ b/img/brand/inbuxa-lockup-light.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/img/demo.gif b/img/demo.gif deleted file mode 100644 index 861577e..0000000 Binary files a/img/demo.gif and /dev/null differ diff --git a/img/logo-red.svg b/img/logo-red.svg deleted file mode 100644 index eb8888a..0000000 --- a/img/logo-red.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/index.html b/index.html index c5a0dfd..f1086eb 100644 --- a/index.html +++ b/index.html @@ -5,9 +5,10 @@ + - Portal + INBUXA Admin diff --git a/package-lock.json b/package-lock.json index 4f355ea..fe1731e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "stalwart-webui", + "name": "inbuxa-admin", "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "stalwart-webui", + "name": "inbuxa-admin", "version": "1.0.7", "dependencies": { "@daypicker/react": "^10.0.1", diff --git a/package.json b/package.json index 2b5e84f..ccb2280 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "stalwart-webui", + "name": "inbuxa-admin", "private": true, "version": "1.0.11", - "description": "Stalwart WebUI", + "description": "INBUXA Admin, a fork of Stalwart WebUI", "type": "module", "scripts": { "dev": "vite", diff --git a/public/favicon.ico b/public/favicon.ico index 22e759e..0b3d262 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/src/assets/inbuxa-mark.png b/src/assets/inbuxa-mark.png new file mode 100644 index 0000000..86cfcf8 Binary files /dev/null and b/src/assets/inbuxa-mark.png differ diff --git a/src/components/bootstrap/BootstrapWizard.tsx b/src/components/bootstrap/BootstrapWizard.tsx index 4674e0e..3403ee6 100644 --- a/src/components/bootstrap/BootstrapWizard.tsx +++ b/src/components/bootstrap/BootstrapWizard.tsx @@ -333,7 +333,7 @@ export function BootstrapWizard() {
-

{t('bootstrap.welcome', 'Welcome to Stalwart')}

+

{t('bootstrap.welcome', 'Welcome to INBUXA')}

{t('bootstrap.welcomeSubtitle', "Let's get your server set up.")}

@@ -468,7 +468,7 @@ function SuccessScreen({ 'bootstrap.credentialsCreated', 'Your administrator account has been created. Write these down now: the password will not be shown again.', ) - : t('bootstrap.configuredSuccessfully', 'Stalwart has been configured successfully.')} + : t('bootstrap.configuredSuccessfully', 'INBUXA has been configured successfully.')}

@@ -500,7 +500,7 @@ function SuccessScreen({ {t('bootstrap.nextStepLabel', 'Next step:')}{' '} {t( 'bootstrap.nextStepBody', - 'restart Stalwart for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.', + 'restart INBUXA for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.', )}

diff --git a/src/components/common/Logo.tsx b/src/components/common/Logo.tsx index 128b0c3..e277ab1 100644 --- a/src/components/common/Logo.tsx +++ b/src/components/common/Logo.tsx @@ -7,24 +7,26 @@ import { useEffect, useSyncExternalStore } from 'react'; import { useTranslation } from 'react-i18next'; import { getLogoState, loadLogoOnce, subscribeToLogo } from '@/lib/logoCache'; +import inbuxaMark from '@/assets/inbuxa-mark.png'; export function DefaultLogo() { const { t } = useTranslation(); + // The INBUXA compact lockup: the mark as an image, the wordmark as vector + // paths in the current text color, so it reads on light and dark themes. return ( - - + + + + + + + ); } diff --git a/src/components/forms/OtpAuthField.tsx b/src/components/forms/OtpAuthField.tsx index 3062c1a..e1647d7 100644 --- a/src/components/forms/OtpAuthField.tsx +++ b/src/components/forms/OtpAuthField.tsx @@ -27,17 +27,15 @@ interface OtpAuthFieldProps { readOnly: boolean; } -const STALWART_IMAGE_URL = 'https://stalw.art/img/favicon-32x32.png'; - function buildOtpAuthUrl(totp: OTPAuth.TOTP): string { - const base = totp.toString(); - const sep = base.includes('?') ? '&' : '?'; - return `${base}${sep}image=${encodeURIComponent(STALWART_IMAGE_URL)}`; + // No `image` parameter: it made authenticator apps fetch a logo from a + // third-party site each time someone set up two-factor. + return totp.toString(); } function generateTotp(): { totp: OTPAuth.TOTP; url: string } { const totp = new OTPAuth.TOTP({ - issuer: 'Stalwart', + issuer: 'INBUXA', label: 'account', algorithm: 'SHA1', digits: 6, diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index 573b934..3013b13 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -88,7 +88,7 @@ export function TopBar() { - {t('version.label', 'Stalwart WebUI v{{version}}', { version: __APP_VERSION__ })} + {t('version.label', 'INBUXA Admin v{{version}}', { version: __APP_VERSION__ })} diff --git a/src/hooks/useDocumentTitle.ts b/src/hooks/useDocumentTitle.ts index 3a8255b..43cc42e 100644 --- a/src/hooks/useDocumentTitle.ts +++ b/src/hooks/useDocumentTitle.ts @@ -6,7 +6,7 @@ import { useEffect } from 'react'; -const APP_NAME = 'Stalwart WebUI'; +const APP_NAME = 'INBUXA Admin'; export function useDocumentTitle(title?: string | null) { useEffect(() => { diff --git a/src/i18n/en.json b/src/i18n/en.json index ac01ded..17c6f26 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -15,7 +15,7 @@ "bootstrap": { "clipboardBlocked": "Your browser blocked clipboard access.", "complete": "Setup complete", - "configuredSuccessfully": "Stalwart has been configured successfully.", + "configuredSuccessfully": "INBUXA has been configured successfully.", "copyFailed": "Copy failed", "credentialsCreated": "Your administrator account has been created. Write these down now: the password will not be shown again.", "emptyForm": "Setup form is empty. The server did not return any bootstrap fields.", @@ -23,11 +23,11 @@ "failedToLoad": "Failed to load bootstrap state.", "finishSetup": "Finish setup", "loadingSetup": "Loading setup...", - "nextStepBody": "restart Stalwart for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.", + "nextStepBody": "restart INBUXA for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.", "nextStepLabel": "Next step:", "noConfirm": "The server did not confirm the update.", "stepOf": "Step {{current}} of {{total}}", - "welcome": "Welcome to Stalwart", + "welcome": "Welcome to INBUXA", "welcomeSubtitle": "Let's get your server set up." }, "common": { @@ -299,11 +299,11 @@ }, "logo": { "alt": "Logo", - "stalwartAlt": "Stalwart Logo" + "stalwartAlt": "INBUXA" }, "logout": "Logout", "version": { - "label": "Stalwart WebUI v{{version}}" + "label": "INBUXA Admin v{{version}}" }, "oauth": { "backToLogin": "Back to login", diff --git a/src/index.css b/src/index.css index e61d4c8..9daf4c1 100644 --- a/src/index.css +++ b/src/index.css @@ -7,81 +7,81 @@ @import 'tailwindcss'; :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0.017 285.823); + --background: #ffffff; + --foreground: #111827; - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0.017 285.823); + --card: #ffffff; + --card-foreground: #111827; - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0.017 285.823); + --popover: #ffffff; + --popover-foreground: #111827; - --content-background: oklch(0.97 0.003 285.823); + --content-background: #f6f8fa; - --primary: oklch(0.205 0.017 285.823); - --primary-foreground: oklch(0.985 0.002 285.823); + --primary: #0f766e; + --primary-foreground: #ffffff; - --secondary: oklch(0.965 0.005 285.823); - --secondary-foreground: oklch(0.205 0.017 285.823); + --secondary: #eef1f4; + --secondary-foreground: #111827; - --muted: oklch(0.93 0.005 285.823); - --muted-foreground: oklch(0.556 0.015 285.823); + --muted: #eef1f4; + --muted-foreground: #5b6472; - --accent: oklch(0.965 0.005 285.823); - --accent-foreground: oklch(0.205 0.017 285.823); + --accent: #d9f1ee; + --accent-foreground: #0b5750; - --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.985 0.002 285.823); + --destructive: #dc2626; + --destructive-foreground: #ffffff; - --border: oklch(0.922 0.007 285.823); - --input: oklch(0.922 0.007 285.823); - --ring: oklch(0.708 0.015 285.823); + --border: #e3e7ec; + --input: #e3e7ec; + --ring: #0f766e; --radius: 0.5rem; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; + --chart-1: #0f766e; + --chart-2: #f9a34c; + --chart-3: #1c4053; + --chart-4: #0e7490; + --chart-5: #b45309; } .dark { - --background: oklch(0.141 0.005 285.823); - --foreground: oklch(0.985 0.002 285.823); + --background: #111a2b; + --foreground: #e5e9f0; - --card: oklch(0.205 0.007 285.823); - --card-foreground: oklch(0.985 0.002 285.823); + --card: #111a2b; + --card-foreground: #e5e9f0; - --popover: oklch(0.205 0.007 285.823); - --popover-foreground: oklch(0.985 0.002 285.823); + --popover: #111a2b; + --popover-foreground: #e5e9f0; - --content-background: oklch(0.115 0.005 285.823); + --content-background: #0b1220; - --primary: oklch(0.985 0.002 285.823); - --primary-foreground: oklch(0.205 0.017 285.823); + --primary: #2dd4bf; + --primary-foreground: #052e2b; - --secondary: oklch(0.274 0.009 285.823); - --secondary-foreground: oklch(0.985 0.002 285.823); + --secondary: #1f2a3d; + --secondary-foreground: #e5e9f0; - --muted: oklch(0.274 0.009 285.823); - --muted-foreground: oklch(0.708 0.015 285.823); + --muted: #1f2a3d; + --muted-foreground: #9aa5b8; - --accent: oklch(0.274 0.009 285.823); - --accent-foreground: oklch(0.985 0.002 285.823); + --accent: #12343a; + --accent-foreground: #99f6e4; - --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.985 0.002 285.823); + --destructive: #f87171; + --destructive-foreground: #0b1220; - --border: oklch(0.274 0.009 285.823); - --input: oklch(0.274 0.009 285.823); - --ring: oklch(0.553 0.013 285.823); + --border: #1f2a3d; + --input: #1f2a3d; + --ring: #2dd4bf; - --chart-1: 220 70% 60%; - --chart-2: 160 60% 55%; - --chart-3: 30 80% 60%; - --chart-4: 280 65% 65%; - --chart-5: 340 75% 60%; + --chart-1: #2dd4bf; + --chart-2: #f9a34c; + --chart-3: #9fc2d6; + --chart-4: #67e8f9; + --chart-5: #fbbf24; } @theme inline { diff --git a/src/services/api.ts b/src/services/api.ts index 8a71eb1..db8df65 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -13,6 +13,12 @@ export function getApiBaseUrl(): string { if (envUrl && envUrl.length > 0) { return envUrl.replace(/\/+$/, ''); } + // Set at deploy time rather than build time, so one build can point at any + // server: . + const injected = document.querySelector('meta[name="api-base-url"]')?.getAttribute('content')?.trim(); + if (injected) { + return injected.replace(/\/+$/, ''); + } return window.location.origin; } diff --git a/src/services/auth/oauth.ts b/src/services/auth/oauth.ts index 5d540d1..5c30b72 100644 --- a/src/services/auth/oauth.ts +++ b/src/services/auth/oauth.ts @@ -32,7 +32,19 @@ export async function discover(username: string): Promise { }), ); } - return response.json() as Promise; + const discovered = (await response.json()) as DiscoveryResponse; + // A server that doesn't know its public URL yet (bootstrap mode, or no + // public URL configured) returns relative endpoints such as "/login". They + // belong to the server, so resolve them against the server's address, not + // this page's: INBUXA Admin is usually served from somewhere else. + const base = `${getApiBaseUrl()}/`; + const absolute = (endpoint: string | undefined) => (endpoint ? new URL(endpoint, base).toString() : endpoint); + return { + ...discovered, + authorization_endpoint: absolute(discovered.authorization_endpoint) as string, + token_endpoint: absolute(discovered.token_endpoint) as string, + end_session_endpoint: absolute(discovered.end_session_endpoint), + }; } const UNRESERVED = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; diff --git a/src/stores/accountStore.test.ts b/src/stores/accountStore.test.ts index de0296f..0865243 100644 --- a/src/stores/accountStore.test.ts +++ b/src/stores/accountStore.test.ts @@ -33,7 +33,8 @@ describe('accountStore', () => { const state = useAccountStore.getState(); expect(state.permissions).toEqual(['b', 'c']); - expect(state.edition).toBe('oss'); + // INBUXA: one edition. A server reporting 'oss' still unlocks everything. + expect(state.edition).toBe('enterprise'); expect(state.locale).toBe('ja'); }); @@ -101,9 +102,11 @@ describe('accountStore', () => { expect(useAccountStore.getState().edition).toBe('enterprise'); }); - it('can be changed to oss', () => { + it('is never oss or community, whatever the server reports (INBUXA: one edition)', () => { useAccountStore.getState().setAccountInfo([], 'oss', 'en'); - expect(useAccountStore.getState().edition).toBe('oss'); + expect(useAccountStore.getState().edition).toBe('enterprise'); + useAccountStore.getState().setAccountInfo([], 'community', 'en'); + expect(useAccountStore.getState().edition).toBe('enterprise'); }); }); }); diff --git a/src/stores/accountStore.ts b/src/stores/accountStore.ts index ce62ed7..d29891d 100644 --- a/src/stores/accountStore.ts +++ b/src/stores/accountStore.ts @@ -23,8 +23,11 @@ export const useAccountStore = create()((set, get) => ({ edition: 'community', locale: 'en', - setAccountInfo: (permissions, edition, locale) => { - set({ permissions, edition, locale }); + setAccountInfo: (permissions, _edition, locale) => { + // INBUXA has one edition, with every feature. Whatever the server reports, + // nothing is hidden or disabled as Enterprise-only. Features the server + // hasn't rebuilt yet answer that for themselves. + set({ permissions, edition: 'enterprise', locale }); }, hasPermission: (perm) => {