Compare commits

...

52 Commits

Author SHA1 Message Date
Stavros
6867667de6 chore: format server package 2025-07-17 00:46:28 +03:00
ElevenNotes
079886b54c feat: better health check and less log noise (#274)
* feat: better health check and less log noise

* feat: better health check and less log noise
2025-07-17 00:44:05 +03:00
Stavros
19eb8f3064 refactor: handle oauth groups response as an any array of any 2025-07-17 00:31:24 +03:00
Stavros
1a13936693 refactor: log parsed user in generic provider 2025-07-16 01:38:54 +03:00
Stavros
af26d705cd fix: add auto complete information to auth forms 2025-07-16 01:29:13 +03:00
dependabot[bot]
2d4ceda12f chore(deps): bump the minor-patch group across 1 directory with 6 updates (#270)
Bumps the minor-patch group with 6 updates in the /frontend directory:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.82.0` | `5.83.0` |
| [zod](https://github.com/colinhacks/zod) | `4.0.2` | `4.0.5` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.30.1` | `9.31.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.0.13` | `24.0.14` |
| [eslint](https://github.com/eslint/eslint) | `9.30.1` | `9.31.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.36.0` | `8.37.0` |



Updates `@tanstack/react-query` from 5.82.0 to 5.83.0
- [Release notes](https://github.com/TanStack/query/releases)
- [Commits](https://github.com/TanStack/query/commits/v5.83.0/packages/react-query)

Updates `zod` from 4.0.2 to 4.0.5
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/commits/v4.0.5)

Updates `@eslint/js` from 9.30.1 to 9.31.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.31.0/packages/js)

Updates `@types/node` from 24.0.13 to 24.0.14
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 9.30.1 to 9.31.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.30.1...v9.31.0)

Updates `typescript-eslint` from 8.36.0 to 8.37.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.37.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.83.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: zod
  dependency-version: 4.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: "@eslint/js"
  dependency-version: 9.31.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: "@types/node"
  dependency-version: 24.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: eslint
  dependency-version: 9.31.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: typescript-eslint
  dependency-version: 8.37.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-15 13:19:12 +03:00
Stavros
4a87af4463 refactor: make ldap user not found errors be warnings 2025-07-15 13:18:37 +03:00
Stavros
88d918d608 fix: don't fail app if LDAP is not configured 2025-07-15 02:24:09 +03:00
Stavros
5854d973ea i18n: internationalize required error 2025-07-15 02:15:17 +03:00
Stavros
f25ab72747 refactor: check cookie prior to basiv auth in context hook 2025-07-15 02:10:16 +03:00
Stavros
2233557990 tests: move handlers test to handlers package 2025-07-15 01:38:01 +03:00
Stavros
d3bec635f8 fix: make tinyauth not "eat" the authorization header 2025-07-15 01:34:25 +03:00
Stavros
6519644fc1 fix: handle type string for oauth groups 2025-07-15 00:17:41 +03:00
Stavros
736f65b7b2 refactor: close connection before trying to reconnect 2025-07-14 20:10:15 +03:00
Stavros
63d39b5500 feat: try to reconnect to ldap server if heartbeat fails 2025-07-14 20:02:16 +03:00
Stavros
b735ab6f39 New Crowdin updates (#260)
* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Afrikaans)

* New translations en.json (Arabic)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Serbian (Cyrillic))

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (English)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Polish)

* New translations en.json (Greek)

* New translations en.json (French)

* New translations en.json (Polish)
2025-07-12 16:23:13 +03:00
Stavros
232c50eaef chore: stop codeconv from failing status checks 2025-07-12 16:13:51 +03:00
Stavros
52b12abeb2 refactor: make heartbeat log message only appear in debug logs 2025-07-12 13:31:53 +03:00
Stavros
48b4d78a7c refactor: split handlers into smaller purpose specific files 2025-07-12 13:23:25 +03:00
Stavros
8ebed0ac9a chore: remove meaningless comments 2025-07-12 13:17:06 +03:00
Stavros
e742603c15 fix: add logging to user parse failure 2025-07-12 11:49:37 +03:00
Stavros
3215bb6baa refactor: simplify ldap heartbeat 2025-07-12 00:21:22 +03:00
Stavros
a11aba72d8 feat: add heartbeat to keep ldap connection alive 2025-07-11 23:16:09 +03:00
Stavros
10d1b48505 chore: add dlv for debugging in dev workflow 2025-07-11 17:15:32 +03:00
Stavros
f73eb9571f fix: fix password reset message translations 2025-07-11 16:16:49 +03:00
dependabot[bot]
da2877a682 chore(deps): bump the minor-patch group across 1 directory with 3 updates (#259)
Bumps the minor-patch group with 3 updates in the /frontend directory: [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query), [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `@tanstack/react-query` from 5.81.5 to 5.82.0
- [Release notes](https://github.com/TanStack/query/releases)
- [Commits](https://github.com/TanStack/query/commits/v5.82.0/packages/react-query)

Updates `@types/node` from 24.0.12 to 24.0.13
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `vite` from 7.0.3 to 7.0.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.0.4/packages/vite)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.82.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: "@types/node"
  dependency-version: 24.0.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: vite
  dependency-version: 7.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-11 16:06:17 +03:00
dependabot[bot]
33cbfef02a chore(deps): bump the minor-patch group across 1 directory with 2 updates (#258)
Bumps the minor-patch group with 2 updates in the / directory: [golang.org/x/crypto](https://github.com/golang/crypto) and [github.com/docker/docker](https://github.com/docker/docker).


Updates `golang.org/x/crypto` from 0.39.0 to 0.40.0
- [Commits](https://github.com/golang/crypto/compare/v0.39.0...v0.40.0)

Updates `github.com/docker/docker` from 28.3.1+incompatible to 28.3.2+incompatible
- [Release notes](https://github.com/docker/docker/releases)
- [Commits](https://github.com/docker/docker/compare/v28.3.1...v28.3.2)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: github.com/docker/docker
  dependency-version: 28.3.2+incompatible
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-11 16:05:54 +03:00
dependabot[bot]
c1a6428ed3 chore(deps): bump zod from 3.25.76 to 4.0.2 in /frontend (#254)
Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.0.2.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/commits)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-11 16:05:34 +03:00
github-actions[bot]
2ee7932cba docs: regenerate readme sponsors list (#249)
Co-authored-by: GitHub <noreply@github.com>
2025-07-10 02:28:58 +03:00
Stavros
fe440a6f2e New translations en.json (Arabic) (#245) 2025-07-10 01:00:34 +03:00
Stavros
0ace88a877 feat: add support for bypassing authentication for specific IPs 2025-07-10 00:53:22 +03:00
Stavros
476ed6964d fix: fix docker label matching logic 2025-07-10 00:34:04 +03:00
Stavros
b3dca0429f New translations en.json (French) (#243) 2025-07-09 23:36:55 +03:00
Vincent Young
9e4b68112c fix: i18n zh-TW locale updates (#242) 2025-07-09 23:30:37 +03:00
dependabot[bot]
364f0e221e chore(deps): bump the minor-patch group in /frontend with 3 updates (#239)
Bumps the minor-patch group in /frontend with 3 updates: [i18next](https://github.com/i18next/i18next), [zod](https://github.com/colinhacks/zod) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `i18next` from 25.3.1 to 25.3.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v25.3.1...v25.3.2)

Updates `zod` from 3.25.75 to 3.25.76
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.75...v3.25.76)

Updates `@types/node` from 24.0.10 to 24.0.12
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: zod
  dependency-version: 3.25.76
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: "@types/node"
  dependency-version: 24.0.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-09 18:05:55 +03:00
Stavros
09635666aa New Crowdin updates (#240)
* New translations en.json (Arabic)

* New translations en.json (Arabic)

* New translations en.json (Danish)

* New translations en.json (Greek)

* New translations en.json (Polish)
2025-07-09 18:05:28 +03:00
Stavros
9f02710114 feat: add support for comma list in label domain check 2025-07-09 17:49:13 +03:00
dependabot[bot]
64bdab5e5b chore(deps-dev): bump the minor-patch group in /frontend with 2 updates (#237)
Bumps the minor-patch group in /frontend with 2 updates: [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `typescript-eslint` from 8.35.1 to 8.36.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.36.0/packages/typescript-eslint)

Updates `vite` from 7.0.2 to 7.0.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.0.3/packages/vite)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.36.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: vite
  dependency-version: 7.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-08 23:50:31 +03:00
Stavros
0f4a6b5924 tests: fix parse header tests 2025-07-08 00:54:36 +03:00
Stavros
c662b9e222 tests: extend tests in utils and server 2025-07-08 00:47:07 +03:00
dependabot[bot]
a4722db7d7 chore(deps): bump zod in /frontend in the minor-patch group (#236)
Bumps the minor-patch group in /frontend with 1 update: [zod](https://github.com/colinhacks/zod).


Updates `zod` from 3.25.74 to 3.25.75
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.74...v3.25.75)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 3.25.75
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 23:37:16 +03:00
Stavros
f48bb65d7b feat: add support for using secret files for basic auth password 2025-07-07 23:31:51 +03:00
Stavros
7e604419ab New Crowdin updates (#192)
* New translations en.json (Spanish)

* New translations en.json (Spanish)

* New translations en.json (German)

* New translations en.json (Chinese Simplified)

* New translations en.json (Spanish)

* New translations en.json (German)

* New translations en.json (Chinese Simplified)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Afrikaans)

* New translations en.json (Arabic)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Serbian (Cyrillic))

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Traditional)

* New translations en.json (English)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Polish)

* New translations en.json (Russian)

* New translations en.json (German)
2025-07-06 01:14:09 +03:00
dependabot[bot]
60cd0a216f chore(deps): bump the minor-patch group across 1 directory with 11 updates (#233)
Bumps the minor-patch group with 11 updates in the /frontend directory:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `25.3.0` | `25.3.1` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.59.0` | `7.60.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `15.5.3` | `15.6.0` |
| [sonner](https://github.com/emilkowalski/sonner) | `2.0.5` | `2.0.6` |
| [zod](https://github.com/colinhacks/zod) | `3.25.67` | `3.25.74` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.30.0` | `9.30.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.0.8` | `24.0.10` |
| [eslint](https://github.com/eslint/eslint) | `9.30.0` | `9.30.1` |
| [globals](https://github.com/sindresorhus/globals) | `16.2.0` | `16.3.0` |
| [tw-animate-css](https://github.com/Wombosvideo/tw-animate-css) | `1.3.4` | `1.3.5` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.0.0` | `7.0.2` |



Updates `i18next` from 25.3.0 to 25.3.1
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v25.3.0...v25.3.1)

Updates `react-hook-form` from 7.59.0 to 7.60.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.59.0...v7.60.0)

Updates `react-i18next` from 15.5.3 to 15.6.0
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v15.5.3...v15.6.0)

Updates `sonner` from 2.0.5 to 2.0.6
- [Release notes](https://github.com/emilkowalski/sonner/releases)
- [Commits](https://github.com/emilkowalski/sonner/compare/v2.0.5...v2.0.6)

Updates `zod` from 3.25.67 to 3.25.74
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.67...v3.25.74)

Updates `@eslint/js` from 9.30.0 to 9.30.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.30.1/packages/js)

Updates `@types/node` from 24.0.8 to 24.0.10
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 9.30.0 to 9.30.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.30.0...v9.30.1)

Updates `globals` from 16.2.0 to 16.3.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v16.2.0...v16.3.0)

Updates `tw-animate-css` from 1.3.4 to 1.3.5
- [Release notes](https://github.com/Wombosvideo/tw-animate-css/releases)
- [Commits](https://github.com/Wombosvideo/tw-animate-css/compare/v1.3.4...v1.3.5)

Updates `vite` from 7.0.0 to 7.0.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.0.2/packages/vite)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: react-hook-form
  dependency-version: 7.60.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: react-i18next
  dependency-version: 15.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: sonner
  dependency-version: 2.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: zod
  dependency-version: 3.25.74
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: "@eslint/js"
  dependency-version: 9.30.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: "@types/node"
  dependency-version: 24.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: eslint
  dependency-version: 9.30.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: globals
  dependency-version: 16.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: tw-animate-css
  dependency-version: 1.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: vite
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-06 01:13:29 +03:00
Stavros
ec6e3aa718 chore: update readme 2025-07-06 01:09:09 +03:00
Stavros
6dc57ddf0f refactor: change basic auth label to username instead of user 2025-07-06 01:02:08 +03:00
Stavros
f780e81ec2 fix: fix error state look in login form 2025-07-06 00:46:59 +03:00
dependabot[bot]
8b70ab47a4 chore(deps): bump the minor-patch group across 1 directory with 2 updates (#223)
Bumps the minor-patch group with 2 updates in the / directory: [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) and [github.com/docker/docker](https://github.com/docker/docker).


Updates `github.com/go-playground/validator/v10` from 10.26.0 to 10.27.0
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.26.0...v10.27.0)

Updates `github.com/docker/docker` from 28.3.0+incompatible to 28.3.1+incompatible
- [Release notes](https://github.com/docker/docker/releases)
- [Commits](https://github.com/docker/docker/compare/v28.3.0...v28.3.1)

---
updated-dependencies:
- dependency-name: github.com/go-playground/validator/v10
  dependency-version: 10.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: github.com/docker/docker
  dependency-version: 28.3.1+incompatible
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-05 18:20:03 +03:00
dependabot[bot]
b800359bb2 chore(deps-dev): bump vite from 6.3.5 to 7.0.0 in /frontend (#213)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 7.0.0.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@7.0.0/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-05 18:19:31 +03:00
Stavros
6ec8c9766c feat: add ldap support (#232)
* feat: add ldap support

* feat: add insecure option for self-signed certificates

* fix: recognize ldap as a username provider

* test: fix tests

* feat: add configurable search filter

* fix: fix error message in ldap search result

* refactor: bot suggestions
2025-07-05 18:17:39 +03:00
dependabot[bot]
4524e3322c chore(deps): bump oven/bun from 1.2.17-alpine to 1.2.18-alpine (#229)
Bumps oven/bun from 1.2.17-alpine to 1.2.18-alpine.

---
updated-dependencies:
- dependency-name: oven/bun
  dependency-version: 1.2.18-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-04 12:05:46 +03:00
Stavros
1941de1125 refactor: remove init functions from methods (#228) 2025-07-04 02:35:09 +03:00
79 changed files with 2295 additions and 2246 deletions

6
.gitignore vendored
View File

@@ -11,11 +11,7 @@ docker-compose.test*
users.txt users.txt
# secret test file # secret test file
secret.txt secret*
secret_oauth.txt
# vscode
.vscode
# apple stuff # apple stuff
.DS_Store .DS_Store

15
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Connect to server",
"type": "go",
"request": "attach",
"mode": "remote",
"remotePath": "/tinyauth",
"port": 4000,
"host": "127.0.0.1",
"debugAdapter": "legacy"
}
]
}

View File

@@ -1,5 +1,5 @@
# Site builder # Site builder
FROM oven/bun:1.2.17-alpine AS frontend-builder FROM oven/bun:1.2.18-alpine AS frontend-builder
WORKDIR /frontend WORKDIR /frontend

View File

@@ -23,27 +23,27 @@ Tinyauth is a simple authentication middleware that adds a simple login screen o
## Getting Started ## Getting Started
You can easily get started with tinyauth by following the guide in the [documentation](https://tinyauth.app/docs/getting-started.html). There is also an available [docker compose](./docker-compose.example.yml) file that has traefik, whoami and tinyauth to demonstrate its capabilities. You can easily get started with Tinyauth by following the guide in the [documentation](https://tinyauth.app/docs/getting-started.html). There is also an available [docker compose](./docker-compose.example.yml) file that has Traefik, Whoami and Tinyauth to demonstrate its capabilities.
## Demo ## Demo
If you are still not sure if tinyauth suits your needs you can try out the [demo](https://demo.tinyauth.app). The default username is `user` and the default password is `password`. If you are still not sure if Tinyauth suits your needs you can try out the [demo](https://demo.tinyauth.app). The default username is `user` and the default password is `password`.
## Documentation ## Documentation
You can find documentation and guides on all of the available configuration of tinyauth in the [website](https://tinyauth.app). You can find documentation and guides on all of the available configuration of Tinyauth in the [website](https://tinyauth.app).
## Discord ## Discord
Tinyauth has a [discord](https://discord.gg/eHzVaCzRRd) server. Feel free to hop in to chat about self-hosting, homelabs and of course tinyauth. See you there! Tinyauth has a [discord](https://discord.gg/eHzVaCzRRd) server. Feel free to hop in to chat about self-hosting, homelabs and of course Tinyauth. See you there!
## Contributing ## Contributing
All contributions to the codebase are welcome! If you have any free time feel free to pick up an [Issue](https://github.com/steveiliop56/tinyauth/issues) or add your own missing features. Make sure to check out the [contributing guide](./CONTRIBUTING.md) for instructions on how to get the development server up and running. All contributions to the codebase are welcome! If you have any free time feel free to pick up an [issue](https://github.com/steveiliop56/tinyauth/issues) or add your own missing features. Make sure to check out the [contributing guide](./CONTRIBUTING.md) for instructions on how to get the development server up and running.
## Localization ## Localization
If you would like to help translate tinyauth into more languages, visit the [Crowdin](https://crowdin.com/project/tinyauth) page. If you would like to help translate Tinyauth into more languages, visit the [Crowdin](https://crowdin.com/project/tinyauth) page.
## License ## License
@@ -51,9 +51,9 @@ Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You ma
## Sponsors ## Sponsors
Thanks a lot to the following people for providing me with more coffee: A big thank you to the following people for providing me with more coffee:
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https:&#x2F;&#x2F;github.com&#x2F;erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>&nbsp;&nbsp;<a href="https://github.com/nicotsx"><img src="https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>&nbsp;&nbsp;<a href="https://github.com/SimpleHomelab"><img src="https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>&nbsp;&nbsp;<a href="https://github.com/jmadden91"><img src="https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>&nbsp;&nbsp;<a href="https://github.com/tribor"><img src="https:&#x2F;&#x2F;github.com&#x2F;tribor.png" width="64px" alt="User avatar: tribor" /></a>&nbsp;&nbsp;<a href="https://github.com/eliasbenb"><img src="https:&#x2F;&#x2F;github.com&#x2F;eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>&nbsp;&nbsp;<!-- sponsors --> <!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https:&#x2F;&#x2F;github.com&#x2F;erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>&nbsp;&nbsp;<a href="https://github.com/nicotsx"><img src="https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>&nbsp;&nbsp;<a href="https://github.com/SimpleHomelab"><img src="https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>&nbsp;&nbsp;<a href="https://github.com/jmadden91"><img src="https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>&nbsp;&nbsp;<a href="https://github.com/tribor"><img src="https:&#x2F;&#x2F;github.com&#x2F;tribor.png" width="64px" alt="User avatar: tribor" /></a>&nbsp;&nbsp;<a href="https://github.com/eliasbenb"><img src="https:&#x2F;&#x2F;github.com&#x2F;eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>&nbsp;&nbsp;<a href="https://github.com/afunworm"><img src="https:&#x2F;&#x2F;github.com&#x2F;afunworm.png" width="64px" alt="User avatar: afunworm" /></a>&nbsp;&nbsp;<!-- sponsors -->
## Acknowledgements ## Acknowledgements

View File

@@ -2,9 +2,9 @@ root = "/tinyauth"
tmp_dir = "tmp" tmp_dir = "tmp"
[build] [build]
pre_cmd = ["mkdir -p internal/assets/dist", "echo 'backend running' > internal/assets/dist/index.html"] pre_cmd = ["mkdir -p internal/assets/dist", "echo 'backend running' > internal/assets/dist/index.html", "go install github.com/go-delve/delve/cmd/dlv@v1.25.0"]
cmd = "CGO_ENABLED=0 go build -o ./tmp/tinyauth ." cmd = "CGO_ENABLED=0 go build -gcflags=\"all=-N -l\" -o tmp/tinyauth ."
bin = "tmp/tinyauth" bin = "/go/bin/dlv --listen :4000 --headless=true --api-version=2 --accept-multiclient --log=true exec tmp/tinyauth --continue"
include_ext = ["go"] include_ext = ["go"]
exclude_dir = ["internal/assets/dist"] exclude_dir = ["internal/assets/dist"]
exclude_regex = [".*_test\\.go"] exclude_regex = [".*_test\\.go"]

View File

@@ -3,18 +3,17 @@ package cmd
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"strings" "strings"
"time"
totpCmd "tinyauth/cmd/totp" totpCmd "tinyauth/cmd/totp"
userCmd "tinyauth/cmd/user" userCmd "tinyauth/cmd/user"
"tinyauth/internal/api"
"tinyauth/internal/auth" "tinyauth/internal/auth"
"tinyauth/internal/constants" "tinyauth/internal/constants"
"tinyauth/internal/docker" "tinyauth/internal/docker"
"tinyauth/internal/handlers" "tinyauth/internal/handlers"
"tinyauth/internal/hooks" "tinyauth/internal/hooks"
"tinyauth/internal/ldap"
"tinyauth/internal/providers" "tinyauth/internal/providers"
"tinyauth/internal/server"
"tinyauth/internal/types" "tinyauth/internal/types"
"tinyauth/internal/utils" "tinyauth/internal/utils"
@@ -30,51 +29,37 @@ var rootCmd = &cobra.Command{
Short: "The simplest way to protect your apps with a login screen.", Short: "The simplest way to protect your apps with a login screen.",
Long: `Tinyauth is a simple authentication middleware that adds simple username/password login or OAuth with Google, Github and any generic OAuth provider to all of your docker apps.`, Long: `Tinyauth is a simple authentication middleware that adds simple username/password login or OAuth with Google, Github and any generic OAuth provider to all of your docker apps.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Logger
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger().Level(zerolog.FatalLevel)
// Get config
var config types.Config var config types.Config
err := viper.Unmarshal(&config) err := viper.Unmarshal(&config)
HandleError(err, "Failed to parse config") HandleError(err, "Failed to parse config")
// Secrets // Check if secrets have a file associated with them
config.Secret = utils.GetSecret(config.Secret, config.SecretFile) config.Secret = utils.GetSecret(config.Secret, config.SecretFile)
config.GithubClientSecret = utils.GetSecret(config.GithubClientSecret, config.GithubClientSecretFile) config.GithubClientSecret = utils.GetSecret(config.GithubClientSecret, config.GithubClientSecretFile)
config.GoogleClientSecret = utils.GetSecret(config.GoogleClientSecret, config.GoogleClientSecretFile) config.GoogleClientSecret = utils.GetSecret(config.GoogleClientSecret, config.GoogleClientSecretFile)
config.GenericClientSecret = utils.GetSecret(config.GenericClientSecret, config.GenericClientSecretFile) config.GenericClientSecret = utils.GetSecret(config.GenericClientSecret, config.GenericClientSecretFile)
// Validate config
validator := validator.New() validator := validator.New()
err = validator.Struct(config) err = validator.Struct(config)
HandleError(err, "Failed to validate config") HandleError(err, "Failed to validate config")
// Logger
log.Logger = log.Level(zerolog.Level(config.LogLevel)) log.Logger = log.Level(zerolog.Level(config.LogLevel))
log.Info().Str("version", strings.TrimSpace(constants.Version)).Msg("Starting tinyauth") log.Info().Str("version", strings.TrimSpace(constants.Version)).Msg("Starting tinyauth")
// Users
log.Info().Msg("Parsing users") log.Info().Msg("Parsing users")
users, err := utils.GetUsers(config.Users, config.UsersFile) users, err := utils.GetUsers(config.Users, config.UsersFile)
HandleError(err, "Failed to parse users") HandleError(err, "Failed to parse users")
if len(users) == 0 && !utils.OAuthConfigured(config) {
HandleError(errors.New("no users or OAuth configured"), "No users or OAuth configured")
}
// Get domain
log.Debug().Msg("Getting domain") log.Debug().Msg("Getting domain")
domain, err := utils.GetUpperDomain(config.AppURL) domain, err := utils.GetUpperDomain(config.AppURL)
HandleError(err, "Failed to get upper domain") HandleError(err, "Failed to get upper domain")
log.Info().Str("domain", domain).Msg("Using domain for cookie store") log.Info().Str("domain", domain).Msg("Using domain for cookie store")
// Generate cookie name
cookieId := utils.GenerateIdentifier(strings.Split(domain, ".")[0]) cookieId := utils.GenerateIdentifier(strings.Split(domain, ".")[0])
sessionCookieName := fmt.Sprintf("%s-%s", constants.SessionCookieName, cookieId) sessionCookieName := fmt.Sprintf("%s-%s", constants.SessionCookieName, cookieId)
csrfCookieName := fmt.Sprintf("%s-%s", constants.CsrfCookieName, cookieId) csrfCookieName := fmt.Sprintf("%s-%s", constants.CsrfCookieName, cookieId)
redirectCookieName := fmt.Sprintf("%s-%s", constants.RedirectCookieName, cookieId) redirectCookieName := fmt.Sprintf("%s-%s", constants.RedirectCookieName, cookieId)
// Generate HMAC and encryption secrets
log.Debug().Msg("Deriving HMAC and encryption secrets") log.Debug().Msg("Deriving HMAC and encryption secrets")
hmacSecret, err := utils.DeriveKey(config.Secret, "hmac") hmacSecret, err := utils.DeriveKey(config.Secret, "hmac")
@@ -83,7 +68,7 @@ var rootCmd = &cobra.Command{
encryptionSecret, err := utils.DeriveKey(config.Secret, "encryption") encryptionSecret, err := utils.DeriveKey(config.Secret, "encryption")
HandleError(err, "Failed to derive encryption secret") HandleError(err, "Failed to derive encryption secret")
// Create OAuth config // Split the config into service-specific sub-configs
oauthConfig := types.OAuthConfig{ oauthConfig := types.OAuthConfig{
GithubClientId: config.GithubClientId, GithubClientId: config.GithubClientId,
GithubClientSecret: config.GithubClientSecret, GithubClientSecret: config.GithubClientSecret,
@@ -99,7 +84,6 @@ var rootCmd = &cobra.Command{
AppURL: config.AppURL, AppURL: config.AppURL,
} }
// Create handlers config
handlersConfig := types.HandlersConfig{ handlersConfig := types.HandlersConfig{
AppURL: config.AppURL, AppURL: config.AppURL,
DisableContinue: config.DisableContinue, DisableContinue: config.DisableContinue,
@@ -114,13 +98,11 @@ var rootCmd = &cobra.Command{
RedirectCookieName: redirectCookieName, RedirectCookieName: redirectCookieName,
} }
// Create api config serverConfig := types.ServerConfig{
apiConfig := types.APIConfig{
Port: config.Port, Port: config.Port,
Address: config.Address, Address: config.Address,
} }
// Create auth config
authConfig := types.AuthConfig{ authConfig := types.AuthConfig{
Users: users, Users: users,
OauthWhitelist: config.OAuthWhitelist, OauthWhitelist: config.OAuthWhitelist,
@@ -134,42 +116,49 @@ var rootCmd = &cobra.Command{
EncryptionSecret: encryptionSecret, EncryptionSecret: encryptionSecret,
} }
// Create hooks config
hooksConfig := types.HooksConfig{ hooksConfig := types.HooksConfig{
Domain: domain, Domain: domain,
} }
// Create docker service var ldapService *ldap.LDAP
docker := docker.NewDocker()
// Initialize docker if config.LdapAddress != "" {
err = docker.Init() log.Info().Msg("Using LDAP for authentication")
ldapConfig := types.LdapConfig{
Address: config.LdapAddress,
BindDN: config.LdapBindDN,
BindPassword: config.LdapBindPassword,
BaseDN: config.LdapBaseDN,
Insecure: config.LdapInsecure,
SearchFilter: config.LdapSearchFilter,
}
ldapService, err = ldap.NewLDAP(ldapConfig)
if err != nil {
log.Error().Err(err).Msg("Failed to initialize LDAP service, disabling LDAP authentication")
ldapService = nil
}
} else {
log.Info().Msg("LDAP not configured, using local users or OAuth")
}
// Check if we have a source of users
if len(users) == 0 && !utils.OAuthConfigured(config) && ldapService == nil {
HandleError(errors.New("err no users"), "Unable to find a source of users")
}
// Setup the services
docker, err := docker.NewDocker()
HandleError(err, "Failed to initialize docker") HandleError(err, "Failed to initialize docker")
auth := auth.NewAuth(authConfig, docker, ldapService)
// Create auth service
auth := auth.NewAuth(authConfig, docker)
// Create OAuth providers service
providers := providers.NewProviders(oauthConfig) providers := providers.NewProviders(oauthConfig)
// Initialize providers
providers.Init()
// Create hooks service
hooks := hooks.NewHooks(hooksConfig, auth, providers) hooks := hooks.NewHooks(hooksConfig, auth, providers)
// Create handlers
handlers := handlers.NewHandlers(handlersConfig, auth, hooks, providers, docker) handlers := handlers.NewHandlers(handlersConfig, auth, hooks, providers, docker)
srv, err := server.NewServer(serverConfig, handlers)
HandleError(err, "Failed to create server")
// Create API // Start up
api := api.NewAPI(apiConfig, handlers) err = srv.Start()
HandleError(err, "Failed to start server")
// Setup routes
api.Init()
api.SetupRoutes()
// Start
api.Run()
}, },
} }
@@ -179,23 +168,17 @@ func Execute() {
} }
func HandleError(err error, msg string) { func HandleError(err error, msg string) {
// If error, log it and exit
if err != nil { if err != nil {
log.Fatal().Err(err).Msg(msg) log.Fatal().Err(err).Msg(msg)
} }
} }
func init() { func init() {
// Add user command
rootCmd.AddCommand(userCmd.UserCmd()) rootCmd.AddCommand(userCmd.UserCmd())
// Add totp command
rootCmd.AddCommand(totpCmd.TotpCmd()) rootCmd.AddCommand(totpCmd.TotpCmd())
// Read environment variables
viper.AutomaticEnv() viper.AutomaticEnv()
// Flags
rootCmd.Flags().Int("port", 3000, "Port to run the server on.") rootCmd.Flags().Int("port", 3000, "Port to run the server on.")
rootCmd.Flags().String("address", "0.0.0.0", "Address to bind the server to.") rootCmd.Flags().String("address", "0.0.0.0", "Address to bind the server to.")
rootCmd.Flags().String("secret", "", "Secret to use for the cookie.") rootCmd.Flags().String("secret", "", "Secret to use for the cookie.")
@@ -227,10 +210,15 @@ func init() {
rootCmd.Flags().Int("login-max-retries", 5, "Maximum login attempts before timeout (0 to disable).") rootCmd.Flags().Int("login-max-retries", 5, "Maximum login attempts before timeout (0 to disable).")
rootCmd.Flags().Int("log-level", 1, "Log level.") rootCmd.Flags().Int("log-level", 1, "Log level.")
rootCmd.Flags().String("app-title", "Tinyauth", "Title of the app.") rootCmd.Flags().String("app-title", "Tinyauth", "Title of the app.")
rootCmd.Flags().String("forgot-password-message", "You can reset your password by changing the `USERS` environment variable.", "Message to show on the forgot password page.") rootCmd.Flags().String("forgot-password-message", "", "Message to show on the forgot password page.")
rootCmd.Flags().String("background-image", "/background.jpg", "Background image URL for the login page.") rootCmd.Flags().String("background-image", "/background.jpg", "Background image URL for the login page.")
rootCmd.Flags().String("ldap-address", "", "LDAP server address (e.g. ldap://localhost:389).")
rootCmd.Flags().String("ldap-bind-dn", "", "LDAP bind DN (e.g. uid=user,dc=example,dc=com).")
rootCmd.Flags().String("ldap-bind-password", "", "LDAP bind password.")
rootCmd.Flags().String("ldap-base-dn", "", "LDAP base DN (e.g. dc=example,dc=com).")
rootCmd.Flags().Bool("ldap-insecure", false, "Skip certificate verification for the LDAP server.")
rootCmd.Flags().String("ldap-search-filter", "(uid=%s)", "LDAP search filter for user lookup.")
// Bind flags to environment
viper.BindEnv("port", "PORT") viper.BindEnv("port", "PORT")
viper.BindEnv("address", "ADDRESS") viper.BindEnv("address", "ADDRESS")
viper.BindEnv("secret", "SECRET") viper.BindEnv("secret", "SECRET")
@@ -264,7 +252,12 @@ func init() {
viper.BindEnv("login-max-retries", "LOGIN_MAX_RETRIES") viper.BindEnv("login-max-retries", "LOGIN_MAX_RETRIES")
viper.BindEnv("forgot-password-message", "FORGOT_PASSWORD_MESSAGE") viper.BindEnv("forgot-password-message", "FORGOT_PASSWORD_MESSAGE")
viper.BindEnv("background-image", "BACKGROUND_IMAGE") viper.BindEnv("background-image", "BACKGROUND_IMAGE")
viper.BindEnv("ldap-address", "LDAP_ADDRESS")
viper.BindEnv("ldap-bind-dn", "LDAP_BIND_DN")
viper.BindEnv("ldap-bind-password", "LDAP_BIND_PASSWORD")
viper.BindEnv("ldap-base-dn", "LDAP_BASE_DN")
viper.BindEnv("ldap-insecure", "LDAP_INSECURE")
viper.BindEnv("ldap-search-filter", "LDAP_SEARCH_FILTER")
// Bind flags to viper
viper.BindPFlags(rootCmd.Flags()) viper.BindPFlags(rootCmd.Flags())
} }

View File

@@ -15,7 +15,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// Interactive flag
var interactive bool var interactive bool
// Input user // Input user
@@ -25,15 +24,9 @@ var GenerateCmd = &cobra.Command{
Use: "generate", Use: "generate",
Short: "Generate a totp secret", Short: "Generate a totp secret",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Setup logger
log.Logger = log.Level(zerolog.InfoLevel) log.Logger = log.Level(zerolog.InfoLevel)
// Use simple theme
var baseTheme *huh.Theme = huh.ThemeBase()
// Interactive
if interactive { if interactive {
// Create huh form
form := huh.NewForm( form := huh.NewForm(
huh.NewGroup( huh.NewGroup(
huh.NewInput().Title("Current username:hash").Value(&iUser).Validate((func(s string) error { huh.NewInput().Title("Current username:hash").Value(&iUser).Validate((func(s string) error {
@@ -44,51 +37,39 @@ var GenerateCmd = &cobra.Command{
})), })),
), ),
) )
var baseTheme *huh.Theme = huh.ThemeBase()
// Run form
err := form.WithTheme(baseTheme).Run() err := form.WithTheme(baseTheme).Run()
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Form failed") log.Fatal().Err(err).Msg("Form failed")
} }
} }
// Parse user
user, err := utils.ParseUser(iUser) user, err := utils.ParseUser(iUser)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to parse user") log.Fatal().Err(err).Msg("Failed to parse user")
} }
// Check if user was using docker escape
dockerEscape := false dockerEscape := false
if strings.Contains(iUser, "$$") { if strings.Contains(iUser, "$$") {
dockerEscape = true dockerEscape = true
} }
// Check it has totp
if user.TotpSecret != "" { if user.TotpSecret != "" {
log.Fatal().Msg("User already has a totp secret") log.Fatal().Msg("User already has a totp secret")
} }
// Generate totp secret
key, err := totp.Generate(totp.GenerateOpts{ key, err := totp.Generate(totp.GenerateOpts{
Issuer: "Tinyauth", Issuer: "Tinyauth",
AccountName: user.Username, AccountName: user.Username,
}) })
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to generate totp secret") log.Fatal().Err(err).Msg("Failed to generate totp secret")
} }
// Create secret
secret := key.Secret() secret := key.Secret()
// Print secret and image
log.Info().Str("secret", secret).Msg("Generated totp secret") log.Info().Str("secret", secret).Msg("Generated totp secret")
// Print QR code
log.Info().Msg("Generated QR code") log.Info().Msg("Generated QR code")
config := qrterminal.Config{ config := qrterminal.Config{
@@ -101,7 +82,6 @@ var GenerateCmd = &cobra.Command{
qrterminal.GenerateWithConfig(key.URL(), config) qrterminal.GenerateWithConfig(key.URL(), config)
// Add the secret to the user
user.TotpSecret = secret user.TotpSecret = secret
// If using docker escape re-escape it // If using docker escape re-escape it
@@ -109,13 +89,11 @@ var GenerateCmd = &cobra.Command{
user.Password = strings.ReplaceAll(user.Password, "$", "$$") user.Password = strings.ReplaceAll(user.Password, "$", "$$")
} }
// Print success
log.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TotpSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.") log.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TotpSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.")
}, },
} }
func init() { func init() {
// Add interactive flag
GenerateCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Run in interactive mode") GenerateCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Run in interactive mode")
GenerateCmd.Flags().StringVar(&iUser, "user", "", "Your current username:hash") GenerateCmd.Flags().StringVar(&iUser, "user", "", "Your current username:hash")
} }

View File

@@ -7,16 +7,11 @@ import (
) )
func TotpCmd() *cobra.Command { func TotpCmd() *cobra.Command {
// Create the totp command
totpCmd := &cobra.Command{ totpCmd := &cobra.Command{
Use: "totp", Use: "totp",
Short: "Totp utilities", Short: "Totp utilities",
Long: `Utilities for creating and verifying totp codes.`, Long: `Utilities for creating and verifying totp codes.`,
} }
// Add the generate command
totpCmd.AddCommand(generate.GenerateCmd) totpCmd.AddCommand(generate.GenerateCmd)
// Return the totp command
return totpCmd return totpCmd
} }

View File

@@ -12,10 +12,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// Interactive flag
var interactive bool var interactive bool
// Docker flag
var docker bool var docker bool
// i stands for input // i stands for input
@@ -27,12 +24,9 @@ var CreateCmd = &cobra.Command{
Short: "Create a user", Short: "Create a user",
Long: `Create a user either interactively or by passing flags.`, Long: `Create a user either interactively or by passing flags.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Setup logger
log.Logger = log.Level(zerolog.InfoLevel) log.Logger = log.Level(zerolog.InfoLevel)
// Check if interactive
if interactive { if interactive {
// Create huh form
form := huh.NewForm( form := huh.NewForm(
huh.NewGroup( huh.NewGroup(
huh.NewInput().Title("Username").Value(&iUsername).Validate((func(s string) error { huh.NewInput().Title("Username").Value(&iUsername).Validate((func(s string) error {
@@ -50,46 +44,35 @@ var CreateCmd = &cobra.Command{
huh.NewSelect[bool]().Title("Format the output for docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&docker), huh.NewSelect[bool]().Title("Format the output for docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&docker),
), ),
) )
// Use simple theme
var baseTheme *huh.Theme = huh.ThemeBase() var baseTheme *huh.Theme = huh.ThemeBase()
err := form.WithTheme(baseTheme).Run() err := form.WithTheme(baseTheme).Run()
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Form failed") log.Fatal().Err(err).Msg("Form failed")
} }
} }
// Do we have username and password?
if iUsername == "" || iPassword == "" { if iUsername == "" || iPassword == "" {
log.Fatal().Err(errors.New("error invalid input")).Msg("Username and password cannot be empty") log.Fatal().Err(errors.New("error invalid input")).Msg("Username and password cannot be empty")
} }
log.Info().Str("username", iUsername).Str("password", iPassword).Bool("docker", docker).Msg("Creating user") log.Info().Str("username", iUsername).Str("password", iPassword).Bool("docker", docker).Msg("Creating user")
// Hash password
password, err := bcrypt.GenerateFromPassword([]byte(iPassword), bcrypt.DefaultCost) password, err := bcrypt.GenerateFromPassword([]byte(iPassword), bcrypt.DefaultCost)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to hash password") log.Fatal().Err(err).Msg("Failed to hash password")
} }
// Convert password to string // If docker format is enabled, escape the dollar sign
passwordString := string(password) passwordString := string(password)
// Escape $ for docker
if docker { if docker {
passwordString = strings.ReplaceAll(passwordString, "$", "$$") passwordString = strings.ReplaceAll(passwordString, "$", "$$")
} }
// Log user created
log.Info().Str("user", fmt.Sprintf("%s:%s", iUsername, passwordString)).Msg("User created") log.Info().Str("user", fmt.Sprintf("%s:%s", iUsername, passwordString)).Msg("User created")
}, },
} }
func init() { func init() {
// Flags
CreateCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively") CreateCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively")
CreateCmd.Flags().BoolVar(&docker, "docker", false, "Format output for docker") CreateCmd.Flags().BoolVar(&docker, "docker", false, "Format output for docker")
CreateCmd.Flags().StringVar(&iUsername, "username", "", "Username") CreateCmd.Flags().StringVar(&iUsername, "username", "", "Username")

View File

@@ -8,17 +8,12 @@ import (
) )
func UserCmd() *cobra.Command { func UserCmd() *cobra.Command {
// Create the user command
userCmd := &cobra.Command{ userCmd := &cobra.Command{
Use: "user", Use: "user",
Short: "User utilities", Short: "User utilities",
Long: `Utilities for creating and verifying tinyauth compatible users.`, Long: `Utilities for creating and verifying tinyauth compatible users.`,
} }
// Add subcommands
userCmd.AddCommand(create.CreateCmd) userCmd.AddCommand(create.CreateCmd)
userCmd.AddCommand(verify.VerifyCmd) userCmd.AddCommand(verify.VerifyCmd)
// Return the user command
return userCmd return userCmd
} }

View File

@@ -12,10 +12,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// Interactive flag
var interactive bool var interactive bool
// Docker flag
var docker bool var docker bool
// i stands for input // i stands for input
@@ -29,15 +26,9 @@ var VerifyCmd = &cobra.Command{
Short: "Verify a user is set up correctly", Short: "Verify a user is set up correctly",
Long: `Verify a user is set up correctly meaning that it has a correct username, password and totp code.`, Long: `Verify a user is set up correctly meaning that it has a correct username, password and totp code.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Setup logger
log.Logger = log.Level(zerolog.InfoLevel) log.Logger = log.Level(zerolog.InfoLevel)
// Use simple theme
var baseTheme *huh.Theme = huh.ThemeBase()
// Check if interactive
if interactive { if interactive {
// Create huh form
form := huh.NewForm( form := huh.NewForm(
huh.NewGroup( huh.NewGroup(
huh.NewInput().Title("User (username:hash:totp)").Value(&iUser).Validate((func(s string) error { huh.NewInput().Title("User (username:hash:totp)").Value(&iUser).Validate((func(s string) error {
@@ -61,35 +52,27 @@ var VerifyCmd = &cobra.Command{
huh.NewInput().Title("Totp Code (if setup)").Value(&iTotp), huh.NewInput().Title("Totp Code (if setup)").Value(&iTotp),
), ),
) )
var baseTheme *huh.Theme = huh.ThemeBase()
// Run form
err := form.WithTheme(baseTheme).Run() err := form.WithTheme(baseTheme).Run()
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Form failed") log.Fatal().Err(err).Msg("Form failed")
} }
} }
// Parse user
user, err := utils.ParseUser(iUser) user, err := utils.ParseUser(iUser)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to parse user") log.Fatal().Err(err).Msg("Failed to parse user")
} }
// Compare username
if user.Username != iUsername { if user.Username != iUsername {
log.Fatal().Msg("Username is incorrect") log.Fatal().Msg("Username is incorrect")
} }
// Compare password
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(iPassword)) err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(iPassword))
if err != nil { if err != nil {
log.Fatal().Msg("Ppassword is incorrect") log.Fatal().Msg("Ppassword is incorrect")
} }
// Check if user has 2fa code
if user.TotpSecret == "" { if user.TotpSecret == "" {
if iTotp != "" { if iTotp != "" {
log.Warn().Msg("User does not have 2fa secret") log.Warn().Msg("User does not have 2fa secret")
@@ -98,21 +81,17 @@ var VerifyCmd = &cobra.Command{
return return
} }
// Check totp code
ok := totp.Validate(iTotp, user.TotpSecret) ok := totp.Validate(iTotp, user.TotpSecret)
if !ok { if !ok {
log.Fatal().Msg("Totp code incorrect") log.Fatal().Msg("Totp code incorrect")
} }
// Done
log.Info().Msg("User verified") log.Info().Msg("User verified")
}, },
} }
func init() { func init() {
// Flags
VerifyCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively") VerifyCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively")
VerifyCmd.Flags().BoolVar(&docker, "docker", false, "Is the user formatted for docker?") VerifyCmd.Flags().BoolVar(&docker, "docker", false, "Is the user formatted for docker?")
VerifyCmd.Flags().StringVar(&iUsername, "username", "", "Username") VerifyCmd.Flags().StringVar(&iUsername, "username", "", "Username")

View File

@@ -7,7 +7,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// Create the version command
var versionCmd = &cobra.Command{ var versionCmd = &cobra.Command{
Use: "version", Use: "version",
Short: "Print the version number of Tinyauth", Short: "Print the version number of Tinyauth",

8
codeconv.yml Normal file
View File

@@ -0,0 +1,8 @@
coverage:
status:
project:
default:
informational: true
patch:
default:
informational: true

View File

@@ -42,6 +42,7 @@ services:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
ports: ports:
- 3000:3000 - 3000:3000
- 4000:4000
labels: labels:
traefik.enable: true traefik.enable: true
traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth-backend:3000/api/auth/traefik traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth-backend:3000/api/auth/traefik

View File

@@ -10,12 +10,12 @@
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.81.5", "@tanstack/react-query": "^5.83.0",
"axios": "^1.10.0", "axios": "^1.10.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"i18next": "^25.3.0", "i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
@@ -23,31 +23,31 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.59.0", "react-hook-form": "^7.60.0",
"react-i18next": "^15.5.3", "react-i18next": "^15.6.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^7.6.3", "react-router": "^7.6.3",
"sonner": "^2.0.5", "sonner": "^2.0.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"zod": "^3.25.67", "zod": "^4.0.5",
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.30.0", "@eslint/js": "^9.31.0",
"@tanstack/eslint-plugin-query": "^5.81.2", "@tanstack/eslint-plugin-query": "^5.81.2",
"@types/node": "^24.0.8", "@types/node": "^24.0.14",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.0", "eslint": "^9.31.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.2.0", "globals": "^16.3.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"tw-animate-css": "^1.3.4", "tw-animate-css": "^1.3.5",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.35.1", "typescript-eslint": "^8.37.0",
"vite": "^6.3.1", "vite": "^7.0.4",
}, },
}, },
}, },
@@ -150,11 +150,11 @@
"@eslint/config-helpers": ["@eslint/config-helpers@0.3.0", "", {}, "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw=="], "@eslint/config-helpers": ["@eslint/config-helpers@0.3.0", "", {}, "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw=="],
"@eslint/core": ["@eslint/core@0.14.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="], "@eslint/core": ["@eslint/core@0.15.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
"@eslint/js": ["@eslint/js@9.30.0", "", {}, "sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww=="], "@eslint/js": ["@eslint/js@9.31.0", "", {}, "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
@@ -328,9 +328,9 @@
"@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.81.2", "", { "dependencies": { "@typescript-eslint/utils": "^8.18.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-h4k6P6fm5VhKP5NkK+0TTVpGGyKQdx6tk7NYYG7J7PkSu7ClpLgBihw7yzK8N3n5zPaF3IMyErxfoNiXWH/3/A=="], "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.81.2", "", { "dependencies": { "@typescript-eslint/utils": "^8.18.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-h4k6P6fm5VhKP5NkK+0TTVpGGyKQdx6tk7NYYG7J7PkSu7ClpLgBihw7yzK8N3n5zPaF3IMyErxfoNiXWH/3/A=="],
"@tanstack/query-core": ["@tanstack/query-core@5.81.5", "", {}, "sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q=="], "@tanstack/query-core": ["@tanstack/query-core@5.83.0", "", {}, "sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.81.5", "", { "dependencies": { "@tanstack/query-core": "5.81.5" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw=="], "@tanstack/react-query": ["@tanstack/react-query@5.83.0", "", { "dependencies": { "@tanstack/query-core": "5.83.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@@ -354,7 +354,7 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@24.0.8", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-WytNrFSgWO/esSH9NbpWUfTMGQwCGIKfCmNlmFDNiI5gGhgMmEA+V1AEvKLeBNvvtBnailJtkrEa2OIISwrVAA=="], "@types/node": ["@types/node@24.0.14", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
@@ -364,25 +364,25 @@
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.35.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/type-utils": "8.35.1", "@typescript-eslint/utils": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.35.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.37.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.37.0", "@typescript-eslint/type-utils": "8.37.0", "@typescript-eslint/utils": "8.37.0", "@typescript-eslint/visitor-keys": "8.37.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.37.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.35.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/typescript-estree": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.37.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.37.0", "@typescript-eslint/types": "8.37.0", "@typescript-eslint/typescript-estree": "8.37.0", "@typescript-eslint/visitor-keys": "8.37.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.34.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.34.1", "@typescript-eslint/types": "^8.34.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA=="], "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.37.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.37.0", "@typescript-eslint/types": "^8.37.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1" } }, "sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA=="], "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1" } }, "sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.34.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg=="], "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.37.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.35.1", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.35.1", "@typescript-eslint/utils": "8.35.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ=="], "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.37.0", "", { "dependencies": { "@typescript-eslint/types": "8.37.0", "@typescript-eslint/typescript-estree": "8.37.0", "@typescript-eslint/utils": "8.37.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.34.1", "", {}, "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA=="], "@typescript-eslint/types": ["@typescript-eslint/types@8.34.1", "", {}, "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.34.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.34.1", "@typescript-eslint/tsconfig-utils": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA=="], "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.37.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.37.0", "@typescript-eslint/tsconfig-utils": "8.37.0", "@typescript-eslint/types": "8.37.0", "@typescript-eslint/visitor-keys": "8.37.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.34.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/typescript-estree": "8.34.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-mqOwUdZ3KjtGk7xJJnLbHxTuWVn3GO2WZZuM+Slhkun4+qthLdXx32C8xIXbO1kfCECb3jIs3eoxK3eryk7aoQ=="], "@typescript-eslint/utils": ["@typescript-eslint/utils@8.34.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/typescript-estree": "8.34.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-mqOwUdZ3KjtGk7xJJnLbHxTuWVn3GO2WZZuM+Slhkun4+qthLdXx32C8xIXbO1kfCECb3jIs3eoxK3eryk7aoQ=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw=="], "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.37.0", "", { "dependencies": { "@typescript-eslint/types": "8.37.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
@@ -494,7 +494,7 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.30.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.0", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.30.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-iN/SiPxmQu6EVkf+m1qpBxzUhE12YqFLOSySuOyVLJLEF9nzTf+h/1AJYc1JWzCnktggeNrjvQGLngDzXirU6g=="], "eslint": ["eslint@9.31.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.0", "@eslint/core": "^0.15.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.31.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
@@ -528,7 +528,7 @@
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
"fdir": ["fdir@6.4.4", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg=="], "fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
@@ -558,7 +558,7 @@
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.2.0", "", {}, "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg=="], "globals": ["globals@16.3.0", "", {}, "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
@@ -582,7 +582,7 @@
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"i18next": ["i18next@25.3.0", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-ZSQIiNGfqSG6yoLHaCvrkPp16UejHI8PCDxFYaNG/1qxtmqNmqEg4JlWKlxkrUmrin2sEjsy+Mjy1TRozBhOgw=="], "i18next": ["i18next@25.3.2", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA=="],
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="], "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="],
@@ -636,27 +636,27 @@
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.29.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.29.2", "lightningcss-darwin-x64": "1.29.2", "lightningcss-freebsd-x64": "1.29.2", "lightningcss-linux-arm-gnueabihf": "1.29.2", "lightningcss-linux-arm64-gnu": "1.29.2", "lightningcss-linux-arm64-musl": "1.29.2", "lightningcss-linux-x64-gnu": "1.29.2", "lightningcss-linux-x64-musl": "1.29.2", "lightningcss-win32-arm64-msvc": "1.29.2", "lightningcss-win32-x64-msvc": "1.29.2" } }, "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA=="], "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.29.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA=="], "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.29.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w=="], "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.29.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg=="], "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.29.2", "", { "os": "linux", "cpu": "arm" }, "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg=="], "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.29.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ=="], "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.29.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ=="], "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.29.2", "", { "os": "linux", "cpu": "x64" }, "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg=="], "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.29.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w=="], "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.29.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw=="], "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.29.2", "", { "os": "win32", "cpu": "x64" }, "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA=="], "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -774,7 +774,7 @@
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -792,9 +792,9 @@
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
"react-hook-form": ["react-hook-form@7.59.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-kmkek2/8grqarTJExFNjy+RXDIP8yM+QTl3QL6m6Q8b2bih4ltmiXxH7T9n+yXNK477xPh5yZT/6vD8sYGzJTA=="], "react-hook-form": ["react-hook-form@7.60.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A=="],
"react-i18next": ["react-i18next@15.5.3", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-ypYmOKOnjqPEJZO4m1BI0kS8kWqkBNsKYyhVUfij0gvjy9xJNoG/VcGkxq5dRlVwzmrmY1BQMAmpbbUBLwC4Kw=="], "react-i18next": ["react-i18next@15.6.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-W135dB0rDfiFmbMipC17nOhGdttO5mzH8BivY+2ybsQBbXvxWIwl3cmeH3T9d+YPBSJu/ouyJKFJTtkK7rJofw=="],
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
@@ -830,7 +830,7 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"sonner": ["sonner@2.0.5", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ=="], "sonner": ["sonner@2.0.6", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -854,7 +854,7 @@
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
"tinyglobby": ["tinyglobby@0.2.13", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw=="], "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
@@ -866,13 +866,13 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.3.4", "", {}, "sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg=="], "tw-animate-css": ["tw-animate-css@1.3.5", "", {}, "sha512-t3u+0YNoloIhj1mMXs779P6MO9q3p3mvGn4k1n3nJPqJw/glZcuijG2qTSN4z4mgNRfW5ZC3aXJFLwDtiipZXA=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript-eslint": ["typescript-eslint@8.35.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.35.1", "@typescript-eslint/parser": "8.35.1", "@typescript-eslint/utils": "8.35.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-xslJjFzhOmHYQzSB/QTeASAHbjmxOGEP6Coh93TXmUBFQoJ1VU35UHIDmG06Jd6taf3wqqC1ntBnCMeymy5Ovw=="], "typescript-eslint": ["typescript-eslint@8.37.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.37.0", "@typescript-eslint/parser": "8.37.0", "@typescript-eslint/typescript-estree": "8.37.0", "@typescript-eslint/utils": "8.37.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-TnbEjzkE9EmcO0Q2zM+GE8NQLItNAJpMmED1BdgoBMYNdqMhzlbqfdSwiRlAzEK2pA9UzVW0gzaaIzXWg2BjfA=="],
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
@@ -900,7 +900,7 @@
"vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="], "vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], "vite": ["vite@7.0.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.2", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
@@ -912,7 +912,7 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@3.25.67", "", {}, "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="], "zod": ["zod@4.0.5", "", {}, "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
@@ -932,9 +932,9 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], "@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.14.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
@@ -960,31 +960,33 @@
"@types/babel__traverse/@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="], "@types/babel__traverse/@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1" } }, "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg=="], "@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.37.0", "", { "dependencies": { "@typescript-eslint/types": "8.37.0", "@typescript-eslint/visitor-keys": "8.37.0" } }, "sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.35.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/typescript-estree": "8.35.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ=="], "@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.37.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.37.0", "@typescript-eslint/types": "8.37.0", "@typescript-eslint/typescript-estree": "8.37.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.4", "", {}, "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.4", "", {}, "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A=="],
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1" } }, "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg=="], "@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.37.0", "", { "dependencies": { "@typescript-eslint/types": "8.37.0", "@typescript-eslint/visitor-keys": "8.37.0" } }, "sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA=="],
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.35.1", "", {}, "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ=="], "@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.37.0", "", {}, "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ=="],
"@typescript-eslint/parser/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.35.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.35.1", "@typescript-eslint/tsconfig-utils": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g=="], "@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.37.0", "", {}, "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ=="],
"@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw=="], "@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.35.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.35.1", "@typescript-eslint/tsconfig-utils": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g=="], "@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.37.0", "", {}, "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.35.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/typescript-estree": "8.35.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ=="], "@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.37.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.37.0", "@typescript-eslint/types": "8.37.0", "@typescript-eslint/typescript-estree": "8.37.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A=="],
"@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw=="], "@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.37.0", "", {}, "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.35.1", "", {}, "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ=="], "@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.34.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.34.1", "@typescript-eslint/tsconfig-utils": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA=="],
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.37.0", "", {}, "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -998,7 +1000,7 @@
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.35.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/typescript-estree": "8.35.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ=="], "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.37.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.37.0", "@typescript-eslint/types": "8.37.0", "@typescript-eslint/typescript-estree": "8.37.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A=="],
"@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.27.1", "", { "dependencies": { "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w=="], "@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.27.1", "", { "dependencies": { "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w=="],
@@ -1010,26 +1012,6 @@
"@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="], "@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
"@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -1040,66 +1022,30 @@
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.35.1", "", {}, "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ=="], "@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.37.0", "", {}, "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.35.1", "", {}, "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ=="], "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.37.0", "", {}, "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.35.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.35.1", "@typescript-eslint/tsconfig-utils": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g=="], "@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.37.0", "", { "dependencies": { "@typescript-eslint/types": "8.37.0", "@typescript-eslint/visitor-keys": "8.37.0" } }, "sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA=="],
"@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.35.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.35.1", "@typescript-eslint/types": "^8.35.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q=="],
"@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.35.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ=="],
"@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/parser/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.35.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.35.1", "@typescript-eslint/types": "^8.35.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.35.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.35.1", "", {}, "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1" } }, "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.35.1", "", {}, "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1" } }, "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg=="], "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.34.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.34.1", "@typescript-eslint/types": "^8.34.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.35.1", "", {}, "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ=="], "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.34.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.35.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.35.1", "@typescript-eslint/tsconfig-utils": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g=="], "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw=="],
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.37.0", "", { "dependencies": { "@typescript-eslint/types": "8.37.0", "@typescript-eslint/visitor-keys": "8.37.0" } }, "sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.37.0", "", {}, "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.35.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.35.1", "@typescript-eslint/types": "^8.35.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q=="], "@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.35.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.35.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.35.1", "@typescript-eslint/types": "^8.35.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.35.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
} }
} }

View File

@@ -16,12 +16,12 @@
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.81.5", "@tanstack/react-query": "^5.83.0",
"axios": "^1.10.0", "axios": "^1.10.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"i18next": "^25.3.0", "i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
@@ -29,30 +29,30 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.59.0", "react-hook-form": "^7.60.0",
"react-i18next": "^15.5.3", "react-i18next": "^15.6.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^7.6.3", "react-router": "^7.6.3",
"sonner": "^2.0.5", "sonner": "^2.0.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"zod": "^3.25.67" "zod": "^4.0.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.30.0", "@eslint/js": "^9.31.0",
"@tanstack/eslint-plugin-query": "^5.81.2", "@tanstack/eslint-plugin-query": "^5.81.2",
"@types/node": "^24.0.8", "@types/node": "^24.0.14",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.0", "eslint": "^9.31.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.2.0", "globals": "^16.3.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"tw-animate-css": "^1.3.4", "tw-animate-css": "^1.3.5",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.35.1", "typescript-eslint": "^8.37.0",
"vite": "^6.3.1" "vite": "^7.0.4"
} }
} }

View File

@@ -12,6 +12,7 @@ import {
} from "../ui/form"; } from "../ui/form";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { loginSchema, LoginSchema } from "@/schemas/login-schema"; import { loginSchema, LoginSchema } from "@/schemas/login-schema";
import z from "zod";
interface Props { interface Props {
onSubmit: (data: LoginSchema) => void; onSubmit: (data: LoginSchema) => void;
@@ -22,6 +23,11 @@ export const LoginForm = (props: Props) => {
const { onSubmit, loading } = props; const { onSubmit, loading } = props;
const { t } = useTranslation(); const { t } = useTranslation();
z.config({
customError: (iss) =>
iss.input === undefined ? t("fieldRequired") : t("invalidInput"),
});
const form = useForm<LoginSchema>({ const form = useForm<LoginSchema>({
resolver: zodResolver(loginSchema), resolver: zodResolver(loginSchema),
}); });
@@ -33,12 +39,13 @@ export const LoginForm = (props: Props) => {
control={form.control} control={form.control}
name="username" name="username"
render={({ field }) => ( render={({ field }) => (
<FormItem className="mb-4"> <FormItem className="mb-4 gap-0">
<FormLabel>{t("loginUsername")}</FormLabel> <FormLabel className="mb-2">{t("loginUsername")}</FormLabel>
<FormControl> <FormControl className="mb-1">
<Input <Input
placeholder={t("loginUsername")} placeholder={t("loginUsername")}
disabled={loading} disabled={loading}
autoComplete="username"
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -50,25 +57,26 @@ export const LoginForm = (props: Props) => {
control={form.control} control={form.control}
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem className="mb-4"> <FormItem className="mb-4 gap-0">
<div className="relative"> <div className="relative mb-1">
<FormLabel className="mb-2">{t("loginPassword")}</FormLabel> <FormLabel className="mb-2">{t("loginPassword")}</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder={t("loginPassword")} placeholder={t("loginPassword")}
type="password" type="password"
disabled={loading} disabled={loading}
autoComplete="current-password"
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage />
<a <a
href="/forgot-password" href="/forgot-password"
className="text-muted-foreground text-sm absolute right-0 bottom-10" className="text-muted-foreground text-sm absolute right-0 bottom-[2.565rem]" // 2.565 is *just* perfect
> >
{t("forgotPasswordTitle")} {t("forgotPasswordTitle")}
</a> </a>
</div> </div>
<FormMessage />
</FormItem> </FormItem>
)} )}
/> />

View File

@@ -8,6 +8,8 @@ import {
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { totpSchema, TotpSchema } from "@/schemas/totp-schema"; import { totpSchema, TotpSchema } from "@/schemas/totp-schema";
import { useTranslation } from "react-i18next";
import z from "zod";
interface Props { interface Props {
formId: string; formId: string;
@@ -17,6 +19,12 @@ interface Props {
export const TotpForm = (props: Props) => { export const TotpForm = (props: Props) => {
const { formId, onSubmit, loading } = props; const { formId, onSubmit, loading } = props;
const { t } = useTranslation();
z.config({
customError: (iss) =>
iss.input === undefined ? t("fieldRequired") : t("invalidInput"),
});
const form = useForm<TotpSchema>({ const form = useForm<TotpSchema>({
resolver: zodResolver(totpSchema), resolver: zodResolver(totpSchema),
@@ -31,7 +39,12 @@ export const TotpForm = (props: Props) => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<InputOTP maxLength={6} disabled={loading} {...field}> <InputOTP
maxLength={6}
disabled={loading}
{...field}
autoComplete="one-time-code"
>
<InputOTPGroup> <InputOTPGroup>
<InputOTPSlot index={0} /> <InputOTPSlot index={0} />
<InputOTPSlot index={1} /> <InputOTPSlot index={1} />

View File

@@ -136,7 +136,7 @@ h4 {
} }
p { p {
@apply leading-6 [&:not(:first-child)]:mt-6; @apply leading-6;
} }
blockquote { blockquote {

View File

@@ -27,8 +27,8 @@ export const languages = {
"tr-TR": "Türkçe", "tr-TR": "Türkçe",
"uk-UA": "Українська", "uk-UA": "Українська",
"vi-VN": "Tiếng Việt", "vi-VN": "Tiếng Việt",
"zh-CN": "中文", "zh-CN": "简体中文",
"zh-TW": "中文", "zh-TW": "繁體中文(台灣)",
}; };
export type SupportedLanguage = keyof typeof languages; export type SupportedLanguage = keyof typeof languages;

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -1,7 +1,7 @@
{ {
"loginTitle": "مرحبا بعودتك، قم بتسجيل الدخول باستخدام", "loginTitle": "مرحبا بعودتك، ادخل باستخدام",
"loginTitleSimple": "Welcome back, please login", "loginTitleSimple": "مرحبا بعودتك، سجل دخولك",
"loginDivider": "Or", "loginDivider": "أو",
"loginUsername": "اسم المستخدم", "loginUsername": "اسم المستخدم",
"loginPassword": "كلمة المرور", "loginPassword": "كلمة المرور",
"loginSubmit": "تسجيل الدخول", "loginSubmit": "تسجيل الدخول",
@@ -10,8 +10,8 @@
"loginFailRateLimit": "You failed to login too many times. Please try again later", "loginFailRateLimit": "You failed to login too many times. Please try again later",
"loginSuccessTitle": "تم تسجيل الدخول", "loginSuccessTitle": "تم تسجيل الدخول",
"loginSuccessSubtitle": "مرحبا بعودتك!", "loginSuccessSubtitle": "مرحبا بعودتك!",
"loginOauthFailTitle": "An error occurred", "loginOauthFailTitle": "حدث خطأ",
"loginOauthFailSubtitle": "فشل في الحصول على رابط OAuth", "loginOauthFailSubtitle": "أخفق الحصول على رابط OAuth",
"loginOauthSuccessTitle": "إعادة توجيه", "loginOauthSuccessTitle": "إعادة توجيه",
"loginOauthSuccessSubtitle": "إعادة توجيه إلى مزود OAuth الخاص بك", "loginOauthSuccessSubtitle": "إعادة توجيه إلى مزود OAuth الخاص بك",
"continueRedirectingTitle": "إعادة توجيه...", "continueRedirectingTitle": "إعادة توجيه...",
@@ -19,7 +19,7 @@
"continueInvalidRedirectTitle": "إعادة توجيه غير صالحة", "continueInvalidRedirectTitle": "إعادة توجيه غير صالحة",
"continueInvalidRedirectSubtitle": "رابط إعادة التوجيه غير صالح", "continueInvalidRedirectSubtitle": "رابط إعادة التوجيه غير صالح",
"continueInsecureRedirectTitle": "إعادة توجيه غير آمنة", "continueInsecureRedirectTitle": "إعادة توجيه غير آمنة",
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?", "continueInsecureRedirectSubtitle": "أنت تحاول إعادة التوجيه من <code>https</code> إلى <code>http</code>، هل أنت متأكد أنك تريد المتابعة؟",
"continueTitle": "متابعة", "continueTitle": "متابعة",
"continueSubtitle": "انقر الزر للمتابعة إلى التطبيق الخاص بك.", "continueSubtitle": "انقر الزر للمتابعة إلى التطبيق الخاص بك.",
"logoutFailTitle": "فشل تسجيل الخروج", "logoutFailTitle": "فشل تسجيل الخروج",
@@ -32,7 +32,7 @@
"notFoundTitle": "الصفحة غير موجودة", "notFoundTitle": "الصفحة غير موجودة",
"notFoundSubtitle": "الصفحة التي تبحث عنها غير موجودة.", "notFoundSubtitle": "الصفحة التي تبحث عنها غير موجودة.",
"notFoundButton": "انتقل إلى الرئيسية", "notFoundButton": "انتقل إلى الرئيسية",
"totpFailTitle": "فشل في التحقق من الرمز", "totpFailTitle": "أخفق التحقق من الرمز",
"totpFailSubtitle": "الرجاء التحقق من الرمز الخاص بك وحاول مرة أخرى", "totpFailSubtitle": "الرجاء التحقق من الرمز الخاص بك وحاول مرة أخرى",
"totpSuccessTitle": "تم التحقق", "totpSuccessTitle": "تم التحقق",
"totpSuccessSubtitle": "إعادة توجيه إلى تطبيقك", "totpSuccessSubtitle": "إعادة توجيه إلى تطبيقك",
@@ -42,12 +42,14 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "حاول مجددا", "unauthorizedButton": "حاول مجددا",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "إعادة توجيه غير موثوقة",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "أنت تحاول إعادة التوجيه إلى نطاق لا يتطابق مع النطاق المكون الخاص بك (<code>{{domain}}</code>). هل أنت متأكد من أنك تريد المتابعة؟",
"cancelTitle": "إلغاء", "cancelTitle": "إلغاء",
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "نسيت كلمة المرور؟",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "حدث خطأ",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "Brugeren med brugernavnet <code>{{username}}</code> har ikke tilladelse til at tilgå ressourcen <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "Brugeren med brugernavnet <code>{{username}}</code> har ikke tilladelse til at tilgå ressourcen <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "Brugeren med brugernavnet <code>{{username}}</code> har ikke tilladelse til at logge ind.", "unauthorizedLoginSubtitle": "Brugeren med brugernavnet <code>{{username}}</code> har ikke tilladelse til at logge ind.",
"unauthorizedGroupsSubtitle": "Brugeren med brugernavnet <code>{{username}}</code> er ikke i de grupper, som ressourcen <code>{{resource}}</code> kræver.", "unauthorizedGroupsSubtitle": "Brugeren med brugernavnet <code>{{username}}</code> er ikke i de grupper, som ressourcen <code>{{resource}}</code> kræver.",
"unauthorizedIpSubtitle": "Din IP adresse <code>{{ip}}</code> er ikke autoriseret til at tilgå ressourcen <code>{{resource}}</code>.",
"unauthorizedButton": "Prøv igen", "unauthorizedButton": "Prøv igen",
"untrustedRedirectTitle": "Usikker omdirigering", "untrustedRedirectTitle": "Usikker omdirigering",
"untrustedRedirectSubtitle": "Du forsøger at omdirigere til et domæne, der ikke matcher dit konfigurerede domæne (<code>{{domain}}</code>). Er du sikker på, at du vil fortsætte?", "untrustedRedirectSubtitle": "Du forsøger at omdirigere til et domæne, der ikke matcher dit konfigurerede domæne (<code>{{domain}}</code>). Er du sikker på, at du vil fortsætte?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Glemt din adgangskode?", "forgotPasswordTitle": "Glemt din adgangskode?",
"failedToFetchProvidersTitle": "Kunne ikke indlæse godkendelsesudbydere. Tjek venligst din konfiguration.", "failedToFetchProvidersTitle": "Kunne ikke indlæse godkendelsesudbydere. Tjek venligst din konfiguration.",
"errorTitle": "Der opstod en fejl", "errorTitle": "Der opstod en fejl",
"errorSubtitle": "Der opstod en fejl under forsøget på at udføre denne handling. Tjek venligst konsollen for mere information." "errorSubtitle": "Der opstod en fejl under forsøget på at udføre denne handling. Tjek venligst konsollen for mere information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -7,7 +7,7 @@
"loginSubmit": "Anmelden", "loginSubmit": "Anmelden",
"loginFailTitle": "Login fehlgeschlagen", "loginFailTitle": "Login fehlgeschlagen",
"loginFailSubtitle": "Bitte überprüfe deinen Benutzernamen und Passwort", "loginFailSubtitle": "Bitte überprüfe deinen Benutzernamen und Passwort",
"loginFailRateLimit": "You failed to login too many times. Please try again later", "loginFailRateLimit": "Zu viele fehlgeschlagene Loginversuche. Versuche es später erneut",
"loginSuccessTitle": "Angemeldet", "loginSuccessTitle": "Angemeldet",
"loginSuccessSubtitle": "Willkommen zurück!", "loginSuccessSubtitle": "Willkommen zurück!",
"loginOauthFailTitle": "Ein Fehler ist aufgetreten", "loginOauthFailTitle": "Ein Fehler ist aufgetreten",
@@ -19,7 +19,7 @@
"continueInvalidRedirectTitle": "Ungültige Weiterleitung", "continueInvalidRedirectTitle": "Ungültige Weiterleitung",
"continueInvalidRedirectSubtitle": "Die Weiterleitungs-URL ist ungültig", "continueInvalidRedirectSubtitle": "Die Weiterleitungs-URL ist ungültig",
"continueInsecureRedirectTitle": "Unsichere Weiterleitung", "continueInsecureRedirectTitle": "Unsichere Weiterleitung",
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?", "continueInsecureRedirectSubtitle": "Sie versuchen von <code>https</code> auf <code>http</code> weiterzuleiten, was unsicher ist. Sind Sie sicher, dass Sie fortfahren möchten?",
"continueTitle": "Weiter", "continueTitle": "Weiter",
"continueSubtitle": "Klicken Sie auf den Button, um zur App zu gelangen.", "continueSubtitle": "Klicken Sie auf den Button, um zur App zu gelangen.",
"logoutFailTitle": "Abmelden fehlgeschlagen", "logoutFailTitle": "Abmelden fehlgeschlagen",
@@ -27,8 +27,8 @@
"logoutSuccessTitle": "Abgemeldet", "logoutSuccessTitle": "Abgemeldet",
"logoutSuccessSubtitle": "Sie wurden abgemeldet", "logoutSuccessSubtitle": "Sie wurden abgemeldet",
"logoutTitle": "Abmelden", "logoutTitle": "Abmelden",
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.", "logoutUsernameSubtitle": "Sie sind derzeit als <code>{{username}}</code> angemeldet. Klicken Sie auf den Button unten, um sich abzumelden.",
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.", "logoutOauthSubtitle": "Sie sind derzeit als <code>{{username}}</code> über den OAuth-Anbieter {{provider}} angemeldet. Klicken Sie auf den Button unten, um sich abzumelden.",
"notFoundTitle": "Seite nicht gefunden", "notFoundTitle": "Seite nicht gefunden",
"notFoundSubtitle": "Die gesuchte Seite existiert nicht.", "notFoundSubtitle": "Die gesuchte Seite existiert nicht.",
"notFoundButton": "Nach Hause", "notFoundButton": "Nach Hause",
@@ -37,17 +37,19 @@
"totpSuccessTitle": "Verifiziert", "totpSuccessTitle": "Verifiziert",
"totpSuccessSubtitle": "Leite zur App weiter", "totpSuccessSubtitle": "Leite zur App weiter",
"totpTitle": "Geben Sie Ihren TOTP Code ein", "totpTitle": "Geben Sie Ihren TOTP Code ein",
"totpSubtitle": "Please enter the code from your authenticator app.", "totpSubtitle": "Bitte geben Sie den Code aus Ihrer Authenticator-App ein.",
"unauthorizedTitle": "Unautorisiert", "unauthorizedTitle": "Unautorisiert",
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "Der Benutzer mit Benutzername <code>{{username}}</code> ist nicht berechtigt, auf die Ressource <code>{{resource}}</code> zuzugreifen.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "Der Benutzer mit Benutzername <code>{{username}}</code> ist nicht berechtigt, sich anzumelden.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "Der Benutzer mit Benutzername <code>{{username}}</code> ist nicht in den Gruppen, die von der Ressource <code>{{resource}}</code> benötigt werden.",
"unauthorizedIpSubtitle": "Ihre IP-Adresse <code>{{ip}}</code> ist nicht berechtigt, auf die Ressource <code>{{resource}}</code> zuzugreifen.",
"unauthorizedButton": "Erneut versuchen", "unauthorizedButton": "Erneut versuchen",
"untrustedRedirectTitle": "Nicht vertrauenswürdige Weiterleitung", "untrustedRedirectTitle": "Nicht vertrauenswürdige Weiterleitung",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "Sie versuchen auf eine Domain umzuleiten, die nicht mit Ihrer konfigurierten Domain übereinstimmt (<code>{{domain}}</code>). Sind Sie sicher, dass Sie fortfahren möchten?",
"cancelTitle": "Abbrechen", "cancelTitle": "Abbrechen",
"forgotPasswordTitle": "Passwort vergessen?", "forgotPasswordTitle": "Passwort vergessen?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Fehler beim Laden der Authentifizierungsanbieter. Bitte überprüfen Sie Ihre Konfiguration.",
"errorTitle": "An error occurred", "errorTitle": "Ein Fehler ist aufgetreten",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "Beim Versuch, diese Aktion auszuführen, ist ein Fehler aufgetreten. Bitte überprüfen Sie die Konsole für weitere Informationen.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "Ο χρήστης με όνομα χρήστη <code>{{username}}</code> δεν έχει άδεια πρόσβασης στον πόρο <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "Ο χρήστης με όνομα χρήστη <code>{{username}}</code> δεν έχει άδεια πρόσβασης στον πόρο <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "Ο χρήστης με όνομα χρήστη <code>{{username}}</code> δεν είναι εξουσιοδοτημένος να συνδεθεί.", "unauthorizedLoginSubtitle": "Ο χρήστης με όνομα χρήστη <code>{{username}}</code> δεν είναι εξουσιοδοτημένος να συνδεθεί.",
"unauthorizedGroupsSubtitle": "Ο χρήστης με όνομα χρήστη <code>{{username}}</code> δεν είναι στις ομάδες που απαιτούνται από τον πόρο <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "Ο χρήστης με όνομα χρήστη <code>{{username}}</code> δεν είναι στις ομάδες που απαιτούνται από τον πόρο <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Η διεύθυνση IP σας <code>{{ip}}</code> δεν είναι εξουσιοδοτημένη να έχει πρόσβαση στον πόρο <code>{{resource}}</code>.",
"unauthorizedButton": "Προσπαθήστε ξανά", "unauthorizedButton": "Προσπαθήστε ξανά",
"untrustedRedirectTitle": "Μη έμπιστη ανακατεύθυνση", "untrustedRedirectTitle": "Μη έμπιστη ανακατεύθυνση",
"untrustedRedirectSubtitle": "Προσπαθείτε να ανακατευθύνετε σε ένα domain που δεν ταιριάζει με τον ρυθμισμένο domain σας (<code>{{domain}}</code>). Είστε βέβαιοι ότι θέλετε να συνεχίσετε;", "untrustedRedirectSubtitle": "Προσπαθείτε να ανακατευθύνετε σε ένα domain που δεν ταιριάζει με τον ρυθμισμένο domain σας (<code>{{domain}}</code>). Είστε βέβαιοι ότι θέλετε να συνεχίσετε;",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Ξεχάσατε το συνθηματικό σας;", "forgotPasswordTitle": "Ξεχάσατε το συνθηματικό σας;",
"failedToFetchProvidersTitle": "Αποτυχία φόρτωσης παρόχων πιστοποίησης. Παρακαλώ ελέγξτε τις ρυθμίσεις σας.", "failedToFetchProvidersTitle": "Αποτυχία φόρτωσης παρόχων πιστοποίησης. Παρακαλώ ελέγξτε τις ρυθμίσεις σας.",
"errorTitle": "Παρουσιάστηκε ένα σφάλμα", "errorTitle": "Παρουσιάστηκε ένα σφάλμα",
"errorSubtitle": "Παρουσιάστηκε σφάλμα κατά την προσπάθεια εκτέλεσης αυτής της ενέργειας. Ελέγξτε την κονσόλα για περισσότερες πληροφορίες." "errorSubtitle": "Παρουσιάστηκε σφάλμα κατά την προσπάθεια εκτέλεσης αυτής της ενέργειας. Ελέγξτε την κονσόλα για περισσότερες πληροφορίες.",
"forgotPasswordMessage": "Μπορείτε να επαναφέρετε τον κωδικό πρόσβασής σας αλλάζοντας τη μεταβλητή περιβάλλοντος `USERS`."
} }

View File

@@ -50,5 +50,8 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
"fieldRequired": "This field is required",
"invalidInput": "Invalid input"
} }

View File

@@ -50,5 +50,8 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
"fieldRequired": "This field is required",
"invalidInput": "Invalid input"
} }

View File

@@ -1,53 +1,55 @@
{ {
"loginTitle": "Bienvenido de nuevo, inicie sesión con", "loginTitle": "Bienvenido de vuelta, inicie sesión con",
"loginTitleSimple": "Welcome back, please login", "loginTitleSimple": "Bienvenido de vuelta, por favor inicie sesión",
"loginDivider": "Or", "loginDivider": "O",
"loginUsername": "Username", "loginUsername": "Usuario",
"loginPassword": "Password", "loginPassword": "Contraseña",
"loginSubmit": "Login", "loginSubmit": "Iniciar sesión",
"loginFailTitle": "Failed to log in", "loginFailTitle": "Fallo al iniciar sesión",
"loginFailSubtitle": "Please check your username and password", "loginFailSubtitle": "Por favor revise su usuario y contraseña",
"loginFailRateLimit": "You failed to login too many times. Please try again later", "loginFailRateLimit": "Muchos inicios de sesión consecutivos fallidos. Por favor inténtelo más tarde",
"loginSuccessTitle": "Logged in", "loginSuccessTitle": "Sesión iniciada",
"loginSuccessSubtitle": "Welcome back!", "loginSuccessSubtitle": "¡Bienvenido de vuelta!",
"loginOauthFailTitle": "An error occurred", "loginOauthFailTitle": "Ocurrió un error",
"loginOauthFailSubtitle": "Error al obtener la URL de OAuth", "loginOauthFailSubtitle": "Error al obtener la URL de OAuth",
"loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessTitle": "Redireccionando",
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", "loginOauthSuccessSubtitle": "Redireccionando a tu proveedor de OAuth",
"continueRedirectingTitle": "Redirecting...", "continueRedirectingTitle": "Redireccionando...",
"continueRedirectingSubtitle": "You should be redirected to the app soon", "continueRedirectingSubtitle": "Pronto será redirigido a la aplicación",
"continueInvalidRedirectTitle": "Invalid redirect", "continueInvalidRedirectTitle": "Redirección inválida",
"continueInvalidRedirectSubtitle": "The redirect URL is invalid", "continueInvalidRedirectSubtitle": "La URL de redirección es inválida",
"continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectTitle": "Redirección insegura",
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?", "continueInsecureRedirectSubtitle": "Está intentando redirigir desde <code>https</code> a <code>http</code> lo cual no es seguro. ¿Está seguro que desea continuar?",
"continueTitle": "Continue", "continueTitle": "Continuar",
"continueSubtitle": "Click the button to continue to your app.", "continueSubtitle": "Haga clic en el botón para continuar hacia su aplicación.",
"logoutFailTitle": "Failed to log out", "logoutFailTitle": "Fallo al cerrar sesión",
"logoutFailSubtitle": "Please try again", "logoutFailSubtitle": "Por favor intente nuevamente",
"logoutSuccessTitle": "Logged out", "logoutSuccessTitle": "Sesión cerrada",
"logoutSuccessSubtitle": "You have been logged out", "logoutSuccessSubtitle": "Su sesión ha sido cerrada",
"logoutTitle": "Logout", "logoutTitle": "Cerrar sesión",
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.", "logoutUsernameSubtitle": "Actualmente está conectado como <code>{{username}}</code>. Haga clic en el botón de abajo para cerrar sesión.",
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.", "logoutOauthSubtitle": "Actualmente está conectado como <code>{{username}}</code> usando {{provider}} como su proveedor de OAuth. Haga clic en el botón de abajo para cerrar sesión.",
"notFoundTitle": "Page not found", "notFoundTitle": "Página no encontrada",
"notFoundSubtitle": "The page you are looking for does not exist.", "notFoundSubtitle": "La página que está buscando no existe.",
"notFoundButton": "Go home", "notFoundButton": "Volver al inicio",
"totpFailTitle": "Failed to verify code", "totpFailTitle": "Error al verificar código",
"totpFailSubtitle": "Please check your code and try again", "totpFailSubtitle": "Por favor compruebe su código e inténtelo de nuevo",
"totpSuccessTitle": "Verified", "totpSuccessTitle": "Verificado",
"totpSuccessSubtitle": "Redirecting to your app", "totpSuccessSubtitle": "Redirigiendo a su aplicación",
"totpTitle": "Enter your TOTP code", "totpTitle": "Ingrese su código TOTP",
"totpSubtitle": "Please enter the code from your authenticator app.", "totpSubtitle": "Por favor introduzca el código de su aplicación de autenticación.",
"unauthorizedTitle": "Unauthorized", "unauthorizedTitle": "No autorizado",
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "El usuario con nombre de usuario <code>{{username}}</code> no está autorizado para acceder al recurso <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "El usuario con nombre de usuario <code>{{username}}</code> no está autorizado a iniciar sesión.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "El usuario con nombre de usuario <code>{{username}}</code> no está en los grupos requeridos por el recurso <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"untrustedRedirectTitle": "Untrusted redirect", "unauthorizedButton": "Inténtelo de nuevo",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectTitle": "Redirección no confiable",
"cancelTitle": "Cancel", "untrustedRedirectSubtitle": "Está intentando redirigir a un dominio que no coincide con su dominio configurado (<code>{{domain}}</code>). ¿Está seguro que desea continuar?",
"forgotPasswordTitle": "Forgot your password?", "cancelTitle": "Cancelar",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "forgotPasswordTitle": "¿Olvidó su contraseña?",
"errorTitle": "An error occurred", "failedToFetchProvidersTitle": "Error al cargar los proveedores de autenticación. Por favor revise su configuración.",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorTitle": "Ha ocurrido un error",
"errorSubtitle": "Ocurrió un error mientras se trataba de realizar esta acción. Por favor, revise la consola para más información.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -1,16 +1,16 @@
{ {
"loginTitle": "Bienvenue, connectez-vous avec", "loginTitle": "Bienvenue, connectez-vous avec",
"loginTitleSimple": "Welcome back, please login", "loginTitleSimple": "De retour parmi nous, veuillez vous connecter",
"loginDivider": "Or", "loginDivider": "Ou",
"loginUsername": "Nom d'utilisateur", "loginUsername": "Nom d'utilisateur",
"loginPassword": "Mot de passe", "loginPassword": "Mot de passe",
"loginSubmit": "Se connecter", "loginSubmit": "Se connecter",
"loginFailTitle": "Échec de la connexion", "loginFailTitle": "Échec de la connexion",
"loginFailSubtitle": "Veuillez vérifier votre nom d'utilisateur et votre mot de passe", "loginFailSubtitle": "Veuillez vérifier votre nom d'utilisateur et votre mot de passe",
"loginFailRateLimit": "You failed to login too many times. Please try again later", "loginFailRateLimit": "Vous avez échoué trop de fois à vous connecter. Veuillez réessayer ultérieurement",
"loginSuccessTitle": "Connecté", "loginSuccessTitle": "Connecté",
"loginSuccessSubtitle": "Bienvenue!", "loginSuccessSubtitle": "Bienvenue!",
"loginOauthFailTitle": "An error occurred", "loginOauthFailTitle": "Une erreur s'est produite",
"loginOauthFailSubtitle": "Impossible d'obtenir l'URL OAuth", "loginOauthFailSubtitle": "Impossible d'obtenir l'URL OAuth",
"loginOauthSuccessTitle": "Redirection", "loginOauthSuccessTitle": "Redirection",
"loginOauthSuccessSubtitle": "Redirection vers votre fournisseur OAuth", "loginOauthSuccessSubtitle": "Redirection vers votre fournisseur OAuth",
@@ -19,7 +19,7 @@
"continueInvalidRedirectTitle": "Redirection invalide", "continueInvalidRedirectTitle": "Redirection invalide",
"continueInvalidRedirectSubtitle": "L'URL de redirection est invalide", "continueInvalidRedirectSubtitle": "L'URL de redirection est invalide",
"continueInsecureRedirectTitle": "Redirection non sécurisée", "continueInsecureRedirectTitle": "Redirection non sécurisée",
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?", "continueInsecureRedirectSubtitle": "Vous tentez de rediriger de <code>https</code> vers <code>http</code>, ce qui n'est pas sécurisé. Êtes-vous sûr de vouloir continuer ?",
"continueTitle": "Continuer", "continueTitle": "Continuer",
"continueSubtitle": "Cliquez sur le bouton pour continuer vers votre application.", "continueSubtitle": "Cliquez sur le bouton pour continuer vers votre application.",
"logoutFailTitle": "Échec de la déconnexion", "logoutFailTitle": "Échec de la déconnexion",
@@ -27,8 +27,8 @@
"logoutSuccessTitle": "Déconnecté", "logoutSuccessTitle": "Déconnecté",
"logoutSuccessSubtitle": "Vous avez été déconnecté", "logoutSuccessSubtitle": "Vous avez été déconnecté",
"logoutTitle": "Déconnexion", "logoutTitle": "Déconnexion",
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.", "logoutUsernameSubtitle": "Vous êtes actuellement connecté en tant que <code>{{username}}</code>. Cliquez sur le bouton ci-dessous pour vous déconnecter.",
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.", "logoutOauthSubtitle": "Vous êtes actuellement connecté en tant que <code>{{username}}</code> via le fournisseur OAuth {{provider}}. Cliquez sur le bouton ci-dessous pour vous déconnecter.",
"notFoundTitle": "Page introuvable", "notFoundTitle": "Page introuvable",
"notFoundSubtitle": "La page recherchée n'existe pas.", "notFoundSubtitle": "La page recherchée n'existe pas.",
"notFoundButton": "Retour à la page d'accueil", "notFoundButton": "Retour à la page d'accueil",
@@ -37,17 +37,19 @@
"totpSuccessTitle": "Vérifié", "totpSuccessTitle": "Vérifié",
"totpSuccessSubtitle": "Redirection vers votre application", "totpSuccessSubtitle": "Redirection vers votre application",
"totpTitle": "Saisissez votre code TOTP", "totpTitle": "Saisissez votre code TOTP",
"totpSubtitle": "Please enter the code from your authenticator app.", "totpSubtitle": "Veuillez saisir le code de votre application d'authentification.",
"unauthorizedTitle": "Non autori", "unauthorizedTitle": "Unauthorized",
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "L'utilisateur avec le nom d'utilisateur <code>{{username}}</code> n'est pas autorisé à accéder à la ressource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "L'utilisateur avec le nom d'utilisateur <code>{{username}}</code> n'est pas autorisé à se connecter.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "L'utilisateur avec le nom d'utilisateur <code>{{username}}</code> n'appartient pas aux groupes requis par la ressource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Votre adresse IP <code>{{ip}}</code> n'est pas autorisée à accéder à la ressource <code>{{resource}}</code>.",
"unauthorizedButton": "Réessayer", "unauthorizedButton": "Réessayer",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Redirection non fiable",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "Vous tentez de rediriger vers un domaine qui ne correspond pas à votre domaine configuré (<code>{{domain}}</code>). Êtes-vous sûr de vouloir continuer ?",
"cancelTitle": "Cancel", "cancelTitle": "Annuler",
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Mot de passe oublié ?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Échec du chargement des fournisseurs d'authentification. Veuillez vérifier votre configuration.",
"errorTitle": "An error occurred", "errorTitle": "Une erreur est survenue",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "Une erreur est survenue lors de l'exécution de cette action. Veuillez consulter la console pour plus d'informations.",
"forgotPasswordMessage": "Vous pouvez réinitialiser votre mot de passe en modifiant la variable d'environnement `USERS`."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Opnieuw proberen", "unauthorizedButton": "Opnieuw proberen",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -1,7 +1,7 @@
{ {
"loginTitle": "Witaj ponownie, zaloguj się przez", "loginTitle": "Witaj ponownie, zaloguj się przez",
"loginTitleSimple": "Witaj ponownie, zaloguj się", "loginTitleSimple": "Witaj ponownie, zaloguj się",
"loginDivider": "lub", "loginDivider": "Lub",
"loginUsername": "Nazwa użytkownika", "loginUsername": "Nazwa użytkownika",
"loginPassword": "Hasło", "loginPassword": "Hasło",
"loginSubmit": "Zaloguj się", "loginSubmit": "Zaloguj się",
@@ -26,7 +26,7 @@
"logoutFailSubtitle": "Spróbuj ponownie", "logoutFailSubtitle": "Spróbuj ponownie",
"logoutSuccessTitle": "Wylogowano", "logoutSuccessTitle": "Wylogowano",
"logoutSuccessSubtitle": "Zostałeś wylogowany", "logoutSuccessSubtitle": "Zostałeś wylogowany",
"logoutTitle": "Wylogowanie", "logoutTitle": "Wyloguj się",
"logoutUsernameSubtitle": "Jesteś obecnie zalogowany jako <code>{{username}}</code>. Kliknij poniższy przycisk, aby się wylogować.", "logoutUsernameSubtitle": "Jesteś obecnie zalogowany jako <code>{{username}}</code>. Kliknij poniższy przycisk, aby się wylogować.",
"logoutOauthSubtitle": "Obecnie jesteś zalogowany jako <code>{{username}}</code> przy użyciu dostawcy {{provider}} OAuth. Kliknij poniższy przycisk, aby się wylogować.", "logoutOauthSubtitle": "Obecnie jesteś zalogowany jako <code>{{username}}</code> przy użyciu dostawcy {{provider}} OAuth. Kliknij poniższy przycisk, aby się wylogować.",
"notFoundTitle": "Nie znaleziono strony", "notFoundTitle": "Nie znaleziono strony",
@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "Użytkownik o nazwie użytkownika <code>{{username}}</code> nie ma uprawnień dostępu do zasobu <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "Użytkownik o nazwie użytkownika <code>{{username}}</code> nie ma uprawnień dostępu do zasobu <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "Użytkownik o nazwie <code>{{username}}</code> nie jest upoważniony do zalogowania się.", "unauthorizedLoginSubtitle": "Użytkownik o nazwie <code>{{username}}</code> nie jest upoważniony do zalogowania się.",
"unauthorizedGroupsSubtitle": "Użytkownik o nazwie <code>{{username}}</code> nie należy do grup wymaganych przez zasób <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "Użytkownik o nazwie <code>{{username}}</code> nie należy do grup wymaganych przez zasób <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Twój adres IP <code>{{ip}}</code> nie ma autoryzacji do dostępu do zasobu <code>{{resource}}</code>.",
"unauthorizedButton": "Spróbuj ponownie", "unauthorizedButton": "Spróbuj ponownie",
"untrustedRedirectTitle": "Niezaufane przekierowanie", "untrustedRedirectTitle": "Niezaufane przekierowanie",
"untrustedRedirectSubtitle": "Próbujesz przekierować do domeny, która nie pasuje do Twojej skonfigurowanej domeny (<code>{{domain}}</code>). Czy na pewno chcesz kontynuować?", "untrustedRedirectSubtitle": "Próbujesz przekierować do domeny, która nie pasuje do Twojej skonfigurowanej domeny (<code>{{domain}}</code>). Czy na pewno chcesz kontynuować?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Nie pamiętasz hasła?", "forgotPasswordTitle": "Nie pamiętasz hasła?",
"failedToFetchProvidersTitle": "Nie udało się załadować dostawców uwierzytelniania. Sprawdź swoją konfigurację.", "failedToFetchProvidersTitle": "Nie udało się załadować dostawców uwierzytelniania. Sprawdź swoją konfigurację.",
"errorTitle": "Wystąpił błąd", "errorTitle": "Wystąpił błąd",
"errorSubtitle": "Wystąpił błąd podczas próby wykonania tej czynności. Sprawdź konsolę, aby uzyskać więcej informacji." "errorSubtitle": "Wystąpił błąd podczas próby wykonania tej czynności. Sprawdź konsolę, aby uzyskać więcej informacji.",
"forgotPasswordMessage": "Możesz zresetować hasło, zmieniając zmienną środowiskową `USERS`."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Tentar novamente", "unauthorizedButton": "Tentar novamente",
"untrustedRedirectTitle": "Redirecionamento não confiável", "untrustedRedirectTitle": "Redirecionamento não confiável",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Esqueceu sua senha?", "forgotPasswordTitle": "Esqueceu sua senha?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "Пользователю <code>{{username}}</code> не разрешен доступ к <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "Пользователю <code>{{username}}</code> не разрешен доступ к <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "Пользователю <code>{{username}}</code> не разрешен вход.", "unauthorizedLoginSubtitle": "Пользователю <code>{{username}}</code> не разрешен вход.",
"unauthorizedGroupsSubtitle": "Пользователь <code>{{username}}</code> не состоит в группах, которым разрешен доступ к <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "Пользователь <code>{{username}}</code> не состоит в группах, которым разрешен доступ к <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Ваш IP адрес <code>{{ip}}</code> не авторизован для доступа к ресурсу <code>{{resource}}</code>.",
"unauthorizedButton": "Повторить", "unauthorizedButton": "Повторить",
"untrustedRedirectTitle": "Ненадежное перенаправление", "untrustedRedirectTitle": "Ненадежное перенаправление",
"untrustedRedirectSubtitle": "Попытка перенаправить на домен, который не соответствует вашему заданному домену (<code>{{domain}}</code>). Уверены, что хотите продолжить?", "untrustedRedirectSubtitle": "Попытка перенаправить на домен, который не соответствует вашему заданному домену (<code>{{domain}}</code>). Уверены, что хотите продолжить?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Забыли пароль?", "forgotPasswordTitle": "Забыли пароль?",
"failedToFetchProvidersTitle": "Не удалось загрузить провайдеров аутентификации. Пожалуйста, проверьте конфигурацию.", "failedToFetchProvidersTitle": "Не удалось загрузить провайдеров аутентификации. Пожалуйста, проверьте конфигурацию.",
"errorTitle": "Произошла ошибка", "errorTitle": "Произошла ошибка",
"errorSubtitle": "Произошла ошибка при попытке выполнить это действие. Проверьте консоль для дополнительной информации." "errorSubtitle": "Произошла ошибка при попытке выполнить это действие. Проверьте консоль для дополнительной информации.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
@@ -49,5 +50,6 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -1,16 +1,16 @@
{ {
"loginTitle": "欢迎回来,请登录", "loginTitle": "欢迎回来,请使用以下方式登录",
"loginTitleSimple": "Welcome back, please login", "loginTitleSimple": "欢迎回来,请登录",
"loginDivider": "或", "loginDivider": "或",
"loginUsername": "用户名", "loginUsername": "用户名",
"loginPassword": "密码", "loginPassword": "密码",
"loginSubmit": "登录", "loginSubmit": "登录",
"loginFailTitle": "登录失败", "loginFailTitle": "登录失败",
"loginFailSubtitle": "请检查您的用户名和密码", "loginFailSubtitle": "请检查您的用户名和密码",
"loginFailRateLimit": "You failed to login too many times. Please try again later", "loginFailRateLimit": "您登录失败次数过多。请稍后再试",
"loginSuccessTitle": "已登录", "loginSuccessTitle": "已登录",
"loginSuccessSubtitle": "欢迎回来!", "loginSuccessSubtitle": "欢迎回来!",
"loginOauthFailTitle": "An error occurred", "loginOauthFailTitle": "发生错误",
"loginOauthFailSubtitle": "获取 OAuth URL 失败", "loginOauthFailSubtitle": "获取 OAuth URL 失败",
"loginOauthSuccessTitle": "重定向中", "loginOauthSuccessTitle": "重定向中",
"loginOauthSuccessSubtitle": "重定向到您的 OAuth 提供商", "loginOauthSuccessSubtitle": "重定向到您的 OAuth 提供商",
@@ -19,7 +19,7 @@
"continueInvalidRedirectTitle": "无效的重定向", "continueInvalidRedirectTitle": "无效的重定向",
"continueInvalidRedirectSubtitle": "重定向URL无效", "continueInvalidRedirectSubtitle": "重定向URL无效",
"continueInsecureRedirectTitle": "不安全的重定向", "continueInsecureRedirectTitle": "不安全的重定向",
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?", "continueInsecureRedirectSubtitle": "您正在尝试从<code>https</code>重定向到<code>http</code>可能存在风险。您确定要继续吗?",
"continueTitle": "继续", "continueTitle": "继续",
"continueSubtitle": "点击按钮以继续您的应用。", "continueSubtitle": "点击按钮以继续您的应用。",
"logoutFailTitle": "注销失败", "logoutFailTitle": "注销失败",
@@ -27,8 +27,8 @@
"logoutSuccessTitle": "已登出", "logoutSuccessTitle": "已登出",
"logoutSuccessSubtitle": "您已登出", "logoutSuccessSubtitle": "您已登出",
"logoutTitle": "登出", "logoutTitle": "登出",
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.", "logoutUsernameSubtitle": "您当前登录用户为<code>{{username}}</code>。点击下方按钮注销。",
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.", "logoutOauthSubtitle": "您当前以<code>{{username}}</code>登录,使用的是{{provider}} OAuth 提供商。点击下方按钮注销。",
"notFoundTitle": "无法找到页面", "notFoundTitle": "无法找到页面",
"notFoundSubtitle": "您正在查找的页面不存在。", "notFoundSubtitle": "您正在查找的页面不存在。",
"notFoundButton": "回到主页", "notFoundButton": "回到主页",
@@ -37,17 +37,19 @@
"totpSuccessTitle": "已验证", "totpSuccessTitle": "已验证",
"totpSuccessSubtitle": "重定向到您的应用", "totpSuccessSubtitle": "重定向到您的应用",
"totpTitle": "输入您的 TOTP 代码", "totpTitle": "输入您的 TOTP 代码",
"totpSubtitle": "Please enter the code from your authenticator app.", "totpSubtitle": "请输入您身份验证器应用中的代码。",
"unauthorizedTitle": "未授权", "unauthorizedTitle": "未授权",
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "用户名为<code>{{username}}</code>的用户无权访问资源<code>{{resource}}</code>",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "用户名为<code>{{username}}</code>的用户无权登录。",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "用户名为<code>{{username}}</code>的用户不在资源<code>{{resource}}</code>所需的组中。",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "重试", "unauthorizedButton": "重试",
"untrustedRedirectTitle": "Untrusted redirect", "untrustedRedirectTitle": "不可信的重定向",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectSubtitle": "您正在尝试重定向到一个与您已配置的域名 (<code>{{domain}}</code>) 不匹配的域名。您确定要继续吗?",
"cancelTitle": "Cancel", "cancelTitle": "取消",
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "忘记密码?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "加载身份验证提供程序失败,请检查您的配置。",
"errorTitle": "An error occurred", "errorTitle": "发生了错误",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorSubtitle": "执行此操作时发生错误,请检查控制台以获取更多信息。",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -1,53 +1,55 @@
{ {
"loginTitle": "Welcome back, login with", "loginTitle": "歡迎回來,請用以下方式登入",
"loginTitleSimple": "Welcome back, please login", "loginTitleSimple": "歡迎回來,請登入",
"loginDivider": "Or", "loginDivider": "",
"loginUsername": "Username", "loginUsername": "帳號",
"loginPassword": "Password", "loginPassword": "密碼",
"loginSubmit": "Login", "loginSubmit": "登入",
"loginFailTitle": "Failed to log in", "loginFailTitle": "登入失敗",
"loginFailSubtitle": "Please check your username and password", "loginFailSubtitle": "請檢查您的帳號與密碼",
"loginFailRateLimit": "You failed to login too many times. Please try again later", "loginFailRateLimit": "登入失敗次數過多,請稍後再試",
"loginSuccessTitle": "Logged in", "loginSuccessTitle": "登入成功",
"loginSuccessSubtitle": "Welcome back!", "loginSuccessSubtitle": "歡迎回來!",
"loginOauthFailTitle": "An error occurred", "loginOauthFailTitle": "發生錯誤",
"loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthFailSubtitle": "無法取得 OAuth 網址",
"loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessTitle": "重新導向中",
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", "loginOauthSuccessSubtitle": "正在將您重新導向至 OAuth 供應商",
"continueRedirectingTitle": "Redirecting...", "continueRedirectingTitle": "重新導向中...",
"continueRedirectingSubtitle": "You should be redirected to the app soon", "continueRedirectingSubtitle": "您即將被重新導向至應用程式",
"continueInvalidRedirectTitle": "Invalid redirect", "continueInvalidRedirectTitle": "無效的重新導向",
"continueInvalidRedirectSubtitle": "The redirect URL is invalid", "continueInvalidRedirectSubtitle": "重新導向的網址無效",
"continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectTitle": "不安全的重新導向",
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?", "continueInsecureRedirectSubtitle": "您正嘗試從安全的 <code>https</code> 重新導向至不安全的 <code>http</code>。您確定要繼續嗎?",
"continueTitle": "Continue", "continueTitle": "繼續",
"continueSubtitle": "Click the button to continue to your app.", "continueSubtitle": "點擊按鈕以繼續前往您的應用程式。",
"logoutFailTitle": "Failed to log out", "logoutFailTitle": "登出失敗",
"logoutFailSubtitle": "Please try again", "logoutFailSubtitle": "請再試一次",
"logoutSuccessTitle": "Logged out", "logoutSuccessTitle": "登出成功",
"logoutSuccessSubtitle": "You have been logged out", "logoutSuccessSubtitle": "您已成功登出",
"logoutTitle": "Logout", "logoutTitle": "登出",
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.", "logoutUsernameSubtitle": "您目前以 <code>{{username}}</code> 的身分登入。點擊下方按鈕以登出。",
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.", "logoutOauthSubtitle": "您目前使用 {{provider}} OAuth 供應商並以 <code>{{username}}</code> 的身分登入。點擊下方按鈕以登出。",
"notFoundTitle": "Page not found", "notFoundTitle": "找不到頁面",
"notFoundSubtitle": "The page you are looking for does not exist.", "notFoundSubtitle": "您要尋找的頁面不存在。",
"notFoundButton": "Go home", "notFoundButton": "回到首頁",
"totpFailTitle": "Failed to verify code", "totpFailTitle": "驗證失敗",
"totpFailSubtitle": "Please check your code and try again", "totpFailSubtitle": "請檢查您的驗證碼並再試一次",
"totpSuccessTitle": "Verified", "totpSuccessTitle": "驗證成功",
"totpSuccessSubtitle": "Redirecting to your app", "totpSuccessSubtitle": "正在重新導向至您的應用程式",
"totpTitle": "Enter your TOTP code", "totpTitle": "輸入您的 TOTP 驗證碼",
"totpSubtitle": "Please enter the code from your authenticator app.", "totpSubtitle": "請輸入您驗證器應用程式中的代碼。",
"unauthorizedTitle": "Unauthorized", "unauthorizedTitle": "未經授權",
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "使用者 <code>{{username}}</code> 未被授權存取資源 <code>{{resource}}</code>",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "使用者 <code>{{username}}</code> 未被授權登入。",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "使用者 <code>{{username}}</code> 不在存取資源 <code>{{resource}}</code> 所需的群組中。",
"unauthorizedButton": "Try again", "unauthorizedIpSubtitle": "您的 IP 位址 <code>{{ip}}</code> 未被授權存取資源 <code>{{resource}}</code>。",
"untrustedRedirectTitle": "Untrusted redirect", "unauthorizedButton": "再試一次",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?", "untrustedRedirectTitle": "不受信任的重新導向",
"cancelTitle": "Cancel", "untrustedRedirectSubtitle": "您正嘗試重新導向至的網域與您設定的網域 (<code>{{domain}}</code>) 不符。您確定要繼續嗎?",
"forgotPasswordTitle": "Forgot your password?", "cancelTitle": "取消",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "forgotPasswordTitle": "忘記密碼?",
"errorTitle": "An error occurred", "failedToFetchProvidersTitle": "載入驗證供應商失敗。請檢查您的設定。",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information." "errorTitle": "發生錯誤",
"errorSubtitle": "執行此操作時發生錯誤。請檢查主控台以獲取更多資訊。",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable."
} }

View File

@@ -17,7 +17,7 @@ export const ForgotPasswordPage = () => {
<CardHeader> <CardHeader>
<CardTitle className="text-3xl">{t("forgotPasswordTitle")}</CardTitle> <CardTitle className="text-3xl">{t("forgotPasswordTitle")}</CardTitle>
<CardDescription> <CardDescription>
<Markdown>{forgotPasswordMessage}</Markdown> <Markdown>{forgotPasswordMessage !== "" ? forgotPasswordMessage : t('forgotPasswordMessage')}</Markdown>
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>

View File

@@ -162,9 +162,9 @@ export const LoginPage = () => {
/> />
)} )}
{configuredProviders.length == 0 && ( {configuredProviders.length == 0 && (
<h3 className="text-center text-xl text-red-600"> <p className="text-center text-red-600 max-w-sm">
{t("failedToFetchProvidersTitle")} {t("failedToFetchProvidersTitle")}
</h3> </p>
)} )}
</CardContent> </CardContent>
</Card> </Card>

20
go.mod
View File

@@ -4,7 +4,7 @@ go 1.23.2
require ( require (
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.10.1
github.com/go-playground/validator/v10 v10.26.0 github.com/go-playground/validator/v10 v10.27.0
github.com/google/go-querystring v1.1.0 github.com/google/go-querystring v1.1.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/mdp/qrterminal/v3 v3.2.1 github.com/mdp/qrterminal/v3 v3.2.1
@@ -12,15 +12,18 @@ require (
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1 github.com/spf13/viper v1.20.1
github.com/traefik/paerser v0.2.2 github.com/traefik/paerser v0.2.2
golang.org/x/crypto v0.39.0 golang.org/x/crypto v0.40.0
) )
require ( require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect github.com/moby/term v0.5.2 // indirect
@@ -29,7 +32,7 @@ require (
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect
go.opentelemetry.io/otel/sdk v1.34.0 // indirect go.opentelemetry.io/otel/sdk v1.34.0 // indirect
golang.org/x/term v0.32.0 // indirect golang.org/x/term v0.33.0 // indirect
gotest.tools/v3 v3.5.2 // indirect gotest.tools/v3 v3.5.2 // indirect
rsc.io/qr v0.2.0 // indirect rsc.io/qr v0.2.0 // indirect
) )
@@ -51,7 +54,7 @@ require (
github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.3.0+incompatible github.com/docker/docker v28.3.2+incompatible
github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
@@ -60,6 +63,7 @@ require (
github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-ldap/ldap/v3 v3.4.11
github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
@@ -106,11 +110,11 @@ require (
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.13.0 // indirect golang.org/x/arch v0.13.0 // indirect
golang.org/x/net v0.38.0 // indirect golang.org/x/net v0.41.0 // indirect
golang.org/x/oauth2 v0.30.0 golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.15.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.26.0 // indirect golang.org/x/text v0.27.0 // indirect
google.golang.org/protobuf v1.36.3 // indirect google.golang.org/protobuf v1.36.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

56
go.sum
View File

@@ -1,9 +1,13 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -22,6 +26,8 @@ github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
@@ -68,8 +74,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.0+incompatible h1:ffS62aKWupCWdvcee7nBU9fhnmknOqDPaJAMtfK0ImQ= github.com/docker/docker v28.3.2+incompatible h1:wn66NJ6pWB1vBZIilP8G3qQPqHy5XymfYn5vsqeA5oA=
github.com/docker/docker v28.3.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v28.3.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -90,6 +96,10 @@ github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -101,8 +111,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
@@ -126,8 +136,22 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@@ -275,8 +299,8 @@ golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -285,15 +309,15 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -303,14 +327,14 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -1,137 +0,0 @@
package api
import (
"fmt"
"io/fs"
"net/http"
"os"
"strings"
"time"
"tinyauth/internal/assets"
"tinyauth/internal/handlers"
"tinyauth/internal/types"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
func NewAPI(config types.APIConfig, handlers *handlers.Handlers) *API {
return &API{
Config: config,
Handlers: handlers,
}
}
type API struct {
Config types.APIConfig
Router *gin.Engine
Handlers *handlers.Handlers
}
func (api *API) Init() {
// Disable gin logs
gin.SetMode(gin.ReleaseMode)
// Create router and use zerolog for logs
log.Debug().Msg("Setting up router")
router := gin.New()
router.Use(zerolog())
// Read UI assets
log.Debug().Msg("Setting up assets")
dist, err := fs.Sub(assets.Assets, "dist")
if err != nil {
log.Fatal().Err(err).Msg("Failed to get UI assets")
}
// Create file server
log.Debug().Msg("Setting up file server")
fileServer := http.FileServer(http.FS(dist))
// UI middleware
router.Use(func(c *gin.Context) {
// If not an API request, serve the UI
if !strings.HasPrefix(c.Request.URL.Path, "/api") {
// Check if the file exists
_, err := fs.Stat(dist, strings.TrimPrefix(c.Request.URL.Path, "/"))
// If the file doesn't exist, serve the index.html
if os.IsNotExist(err) {
c.Request.URL.Path = "/"
}
// Serve the file
fileServer.ServeHTTP(c.Writer, c.Request)
// Stop further processing
c.Abort()
}
})
// Set router
api.Router = router
}
func (api *API) SetupRoutes() {
// Proxy
api.Router.GET("/api/auth/:proxy", api.Handlers.AuthHandler)
// Auth
api.Router.POST("/api/login", api.Handlers.LoginHandler)
api.Router.POST("/api/totp", api.Handlers.TotpHandler)
api.Router.POST("/api/logout", api.Handlers.LogoutHandler)
// Context
api.Router.GET("/api/app", api.Handlers.AppHandler)
api.Router.GET("/api/user", api.Handlers.UserHandler)
// OAuth
api.Router.GET("/api/oauth/url/:provider", api.Handlers.OauthUrlHandler)
api.Router.GET("/api/oauth/callback/:provider", api.Handlers.OauthCallbackHandler)
// App
api.Router.GET("/api/healthcheck", api.Handlers.HealthcheckHandler)
}
func (api *API) Run() {
log.Info().Str("address", api.Config.Address).Int("port", api.Config.Port).Msg("Starting server")
// Run server
err := api.Router.Run(fmt.Sprintf("%s:%d", api.Config.Address, api.Config.Port))
// Check for errors
if err != nil {
log.Fatal().Err(err).Msg("Failed to start server")
}
}
// zerolog is a middleware for gin that logs requests using zerolog
func zerolog() gin.HandlerFunc {
return func(c *gin.Context) {
// Get initial time
tStart := time.Now()
// Process request
c.Next()
// Get status code, address, method and path
code := c.Writer.Status()
address := c.Request.RemoteAddr
method := c.Request.Method
path := c.Request.URL.Path
// Get latency
latency := time.Since(tStart).String()
// Log request
switch {
case code >= 200 && code < 300:
log.Info().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 300 && code < 400:
log.Warn().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 400:
log.Error().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
}
}
}

View File

@@ -7,6 +7,7 @@ import (
"sync" "sync"
"time" "time"
"tinyauth/internal/docker" "tinyauth/internal/docker"
"tinyauth/internal/ldap"
"tinyauth/internal/types" "tinyauth/internal/types"
"tinyauth/internal/utils" "tinyauth/internal/utils"
@@ -16,48 +17,43 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
func NewAuth(config types.AuthConfig, docker *docker.Docker) *Auth {
return &Auth{
Config: config,
Docker: docker,
LoginAttempts: make(map[string]*types.LoginAttempt),
}
}
type Auth struct { type Auth struct {
Config types.AuthConfig Config types.AuthConfig
Docker *docker.Docker Docker *docker.Docker
LoginAttempts map[string]*types.LoginAttempt LoginAttempts map[string]*types.LoginAttempt
LoginMutex sync.RWMutex LoginMutex sync.RWMutex
Store *sessions.CookieStore
LDAP *ldap.LDAP
}
func NewAuth(config types.AuthConfig, docker *docker.Docker, ldap *ldap.LDAP) *Auth {
// Setup cookie store and create the auth service
store := sessions.NewCookieStore([]byte(config.HMACSecret), []byte(config.EncryptionSecret))
store.Options = &sessions.Options{
Path: "/",
MaxAge: config.SessionExpiry,
Secure: config.CookieSecure,
HttpOnly: true,
Domain: fmt.Sprintf(".%s", config.Domain),
}
return &Auth{
Config: config,
Docker: docker,
LoginAttempts: make(map[string]*types.LoginAttempt),
Store: store,
LDAP: ldap,
}
} }
func (auth *Auth) GetSession(c *gin.Context) (*sessions.Session, error) { func (auth *Auth) GetSession(c *gin.Context) (*sessions.Session, error) {
// Create cookie store session, err := auth.Store.Get(c.Request, auth.Config.SessionCookieName)
store := sessions.NewCookieStore([]byte(auth.Config.HMACSecret), []byte(auth.Config.EncryptionSecret))
// Configure cookie store
store.Options = &sessions.Options{
Path: "/",
MaxAge: auth.Config.SessionExpiry,
Secure: auth.Config.CookieSecure,
HttpOnly: true,
Domain: fmt.Sprintf(".%s", auth.Config.Domain),
}
// Get session
session, err := store.Get(c.Request, auth.Config.SessionCookieName)
// If there was an error getting the session, it might be invalid so let's clear it and retry
if err != nil { if err != nil {
log.Warn().Err(err).Msg("Invalid session, clearing cookie and retrying") log.Error().Err(err).Msg("Invalid session, clearing cookie and retrying")
// Delete the session cookie if there is an error
c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.Config.Domain), auth.Config.CookieSecure, true) c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.Config.Domain), auth.Config.CookieSecure, true)
session, err = auth.Store.Get(c.Request, auth.Config.SessionCookieName)
// Try to get the session again
session, err = store.Get(c.Request, auth.Config.SessionCookieName)
if err != nil { if err != nil {
// If we still can't get the session, log the error and return nil
log.Error().Err(err).Msg("Failed to get session") log.Error().Err(err).Msg("Failed to get session")
return nil, err return nil, err
} }
@@ -66,22 +62,94 @@ func (auth *Auth) GetSession(c *gin.Context) (*sessions.Session, error) {
return session, nil return session, nil
} }
func (auth *Auth) GetUser(username string) *types.User { func (auth *Auth) SearchUser(username string) types.UserSearch {
// Loop through users and return the user if the username matches log.Debug().Str("username", username).Msg("Searching for user")
for _, user := range auth.Config.Users {
if user.Username == username { // Check local users first
return &user if auth.GetLocalUser(username).Username != "" {
log.Debug().Str("username", username).Msg("Found local user")
return types.UserSearch{
Username: username,
Type: "local",
} }
} }
return nil
// If no user found, check LDAP
if auth.LDAP != nil {
log.Debug().Str("username", username).Msg("Checking LDAP for user")
userDN, err := auth.LDAP.Search(username)
if err != nil {
log.Warn().Err(err).Str("username", username).Msg("Failed to find user in LDAP")
return types.UserSearch{}
}
return types.UserSearch{
Username: userDN,
Type: "ldap",
}
}
return types.UserSearch{
Type: "unknown",
}
}
func (auth *Auth) VerifyUser(search types.UserSearch, password string) bool {
// Authenticate the user based on the type
switch search.Type {
case "local":
// If local user, get the user and check the password
user := auth.GetLocalUser(search.Username)
return auth.CheckPassword(user, password)
case "ldap":
// If LDAP is configured, bind to the LDAP server with the user DN and password
if auth.LDAP != nil {
log.Debug().Str("username", search.Username).Msg("Binding to LDAP for user authentication")
err := auth.LDAP.Bind(search.Username, password)
if err != nil {
log.Warn().Err(err).Str("username", search.Username).Msg("Failed to bind to LDAP")
return false
}
// Rebind with the service account to reset the connection
err = auth.LDAP.Bind(auth.LDAP.Config.BindDN, auth.LDAP.Config.BindPassword)
if err != nil {
log.Error().Err(err).Msg("Failed to rebind with service account after user authentication")
return false
}
log.Debug().Str("username", search.Username).Msg("LDAP authentication successful")
return true
}
default:
log.Warn().Str("type", search.Type).Msg("Unknown user type for authentication")
return false
}
// If no user found or authentication failed, return false
log.Warn().Str("username", search.Username).Msg("User authentication failed")
return false
}
func (auth *Auth) GetLocalUser(username string) types.User {
// Loop through users and return the user if the username matches
log.Debug().Str("username", username).Msg("Searching for local user")
for _, user := range auth.Config.Users {
if user.Username == username {
return user
}
}
// If no user found, return an empty user
log.Warn().Str("username", username).Msg("Local user not found")
return types.User{}
} }
func (auth *Auth) CheckPassword(user types.User, password string) bool { func (auth *Auth) CheckPassword(user types.User, password string) bool {
// Compare the hashed password with the password provided
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil
} }
// IsAccountLocked checks if a username or IP is locked due to too many failed login attempts
func (auth *Auth) IsAccountLocked(identifier string) (bool, int) { func (auth *Auth) IsAccountLocked(identifier string) (bool, int) {
auth.LoginMutex.RLock() auth.LoginMutex.RLock()
defer auth.LoginMutex.RUnlock() defer auth.LoginMutex.RUnlock()
@@ -108,7 +176,6 @@ func (auth *Auth) IsAccountLocked(identifier string) (bool, int) {
return false, 0 return false, 0
} }
// RecordLoginAttempt records a login attempt for rate limiting
func (auth *Auth) RecordLoginAttempt(identifier string, success bool) { func (auth *Auth) RecordLoginAttempt(identifier string, success bool) {
// Skip if rate limiting is not configured // Skip if rate limiting is not configured
if auth.Config.LoginMaxRetries <= 0 || auth.Config.LoginTimeout <= 0 { if auth.Config.LoginMaxRetries <= 0 || auth.Config.LoginTimeout <= 0 {
@@ -145,14 +212,13 @@ func (auth *Auth) RecordLoginAttempt(identifier string, success bool) {
} }
} }
func (auth *Auth) EmailWhitelisted(emailSrc string) bool { func (auth *Auth) EmailWhitelisted(email string) bool {
return utils.CheckWhitelist(auth.Config.OauthWhitelist, emailSrc) return utils.CheckFilter(auth.Config.OauthWhitelist, email)
} }
func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie) error { func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie) error {
log.Debug().Msg("Creating session cookie") log.Debug().Msg("Creating session cookie")
// Get session
session, err := auth.GetSession(c) session, err := auth.GetSession(c)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to get session") log.Error().Err(err).Msg("Failed to get session")
@@ -161,7 +227,6 @@ func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie)
log.Debug().Msg("Setting session cookie") log.Debug().Msg("Setting session cookie")
// Calculate expiry
var sessionExpiry int var sessionExpiry int
if data.TotpPending { if data.TotpPending {
@@ -170,7 +235,6 @@ func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie)
sessionExpiry = auth.Config.SessionExpiry sessionExpiry = auth.Config.SessionExpiry
} }
// Set data
session.Values["username"] = data.Username session.Values["username"] = data.Username
session.Values["name"] = data.Name session.Values["name"] = data.Name
session.Values["email"] = data.Email session.Values["email"] = data.Email
@@ -179,21 +243,18 @@ func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie)
session.Values["totpPending"] = data.TotpPending session.Values["totpPending"] = data.TotpPending
session.Values["oauthGroups"] = data.OAuthGroups session.Values["oauthGroups"] = data.OAuthGroups
// Save session
err = session.Save(c.Request, c.Writer) err = session.Save(c.Request, c.Writer)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to save session") log.Error().Err(err).Msg("Failed to save session")
return err return err
} }
// Return nil
return nil return nil
} }
func (auth *Auth) DeleteSessionCookie(c *gin.Context) error { func (auth *Auth) DeleteSessionCookie(c *gin.Context) error {
log.Debug().Msg("Deleting session cookie") log.Debug().Msg("Deleting session cookie")
// Get session
session, err := auth.GetSession(c) session, err := auth.GetSession(c)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to get session") log.Error().Err(err).Msg("Failed to get session")
@@ -205,21 +266,18 @@ func (auth *Auth) DeleteSessionCookie(c *gin.Context) error {
delete(session.Values, key) delete(session.Values, key)
} }
// Save session
err = session.Save(c.Request, c.Writer) err = session.Save(c.Request, c.Writer)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to save session") log.Error().Err(err).Msg("Failed to save session")
return err return err
} }
// Return nil
return nil return nil
} }
func (auth *Auth) GetSessionCookie(c *gin.Context) (types.SessionCookie, error) { func (auth *Auth) GetSessionCookie(c *gin.Context) (types.SessionCookie, error) {
log.Debug().Msg("Getting session cookie") log.Debug().Msg("Getting session cookie")
// Get session
session, err := auth.GetSession(c) session, err := auth.GetSession(c)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to get session") log.Error().Err(err).Msg("Failed to get session")
@@ -228,7 +286,6 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) (types.SessionCookie, error)
log.Debug().Msg("Got session") log.Debug().Msg("Got session")
// Get data from session
username, usernameOk := session.Values["username"].(string) username, usernameOk := session.Values["username"].(string)
email, emailOk := session.Values["email"].(string) email, emailOk := session.Values["email"].(string)
name, nameOk := session.Values["name"].(string) name, nameOk := session.Values["name"].(string)
@@ -237,30 +294,21 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) (types.SessionCookie, error)
totpPending, totpPendingOk := session.Values["totpPending"].(bool) totpPending, totpPendingOk := session.Values["totpPending"].(bool)
oauthGroups, oauthGroupsOk := session.Values["oauthGroups"].(string) oauthGroups, oauthGroupsOk := session.Values["oauthGroups"].(string)
// If any data is missing, delete the session cookie
if !usernameOk || !providerOK || !expiryOk || !totpPendingOk || !emailOk || !nameOk || !oauthGroupsOk { if !usernameOk || !providerOK || !expiryOk || !totpPendingOk || !emailOk || !nameOk || !oauthGroupsOk {
log.Warn().Msg("Session cookie is invalid") log.Warn().Msg("Session cookie is invalid")
// If any data is missing, delete the session cookie
auth.DeleteSessionCookie(c) auth.DeleteSessionCookie(c)
// Return empty cookie
return types.SessionCookie{}, nil return types.SessionCookie{}, nil
} }
// Check if the cookie has expired // If the session cookie has expired, delete it
if time.Now().Unix() > expiry { if time.Now().Unix() > expiry {
log.Warn().Msg("Session cookie expired") log.Warn().Msg("Session cookie expired")
// If it has, delete it
auth.DeleteSessionCookie(c) auth.DeleteSessionCookie(c)
// Return empty cookie
return types.SessionCookie{}, nil return types.SessionCookie{}, nil
} }
log.Debug().Str("username", username).Str("provider", provider).Int64("expiry", expiry).Bool("totpPending", totpPending).Str("name", name).Str("email", email).Str("oauthGroups", oauthGroups).Msg("Parsed cookie") log.Debug().Str("username", username).Str("provider", provider).Int64("expiry", expiry).Bool("totpPending", totpPending).Str("name", name).Str("email", email).Str("oauthGroups", oauthGroups).Msg("Parsed cookie")
// Return the cookie
return types.SessionCookie{ return types.SessionCookie{
Username: username, Username: username,
Name: name, Name: name,
@@ -272,25 +320,21 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) (types.SessionCookie, error)
} }
func (auth *Auth) UserAuthConfigured() bool { func (auth *Auth) UserAuthConfigured() bool {
// If there are users, return true // If there are users or LDAP is configured, return true
return len(auth.Config.Users) > 0 return len(auth.Config.Users) > 0 || auth.LDAP != nil
} }
func (auth *Auth) ResourceAllowed(c *gin.Context, context types.UserContext, labels types.Labels) bool { func (auth *Auth) ResourceAllowed(c *gin.Context, context types.UserContext, labels types.Labels) bool {
// Check if oauth is allowed
if context.OAuth { if context.OAuth {
log.Debug().Msg("Checking OAuth whitelist") log.Debug().Msg("Checking OAuth whitelist")
return utils.CheckWhitelist(labels.OAuth.Whitelist, context.Email) return utils.CheckFilter(labels.OAuth.Whitelist, context.Email)
} }
// Check users
log.Debug().Msg("Checking users") log.Debug().Msg("Checking users")
return utils.CheckFilter(labels.Users, context.Username)
return utils.CheckWhitelist(labels.Users, context.Username)
} }
func (auth *Auth) OAuthGroup(c *gin.Context, context types.UserContext, labels types.Labels) bool { func (auth *Auth) OAuthGroup(c *gin.Context, context types.UserContext, labels types.Labels) bool {
// Check if groups are required
if labels.OAuth.Groups == "" { if labels.OAuth.Groups == "" {
return true return true
} }
@@ -306,7 +350,7 @@ func (auth *Auth) OAuthGroup(c *gin.Context, context types.UserContext, labels t
// For every group check if it is in the required groups // For every group check if it is in the required groups
for _, group := range oauthGroups { for _, group := range oauthGroups {
if utils.CheckWhitelist(labels.OAuth.Groups, group) { if utils.CheckFilter(labels.OAuth.Groups, group) {
log.Debug().Str("group", group).Msg("Group is in required groups") log.Debug().Str("group", group).Msg("Group is in required groups")
return true return true
} }
@@ -314,18 +358,12 @@ func (auth *Auth) OAuthGroup(c *gin.Context, context types.UserContext, labels t
// No groups matched // No groups matched
log.Debug().Msg("No groups matched") log.Debug().Msg("No groups matched")
// Return false
return false return false
} }
func (auth *Auth) AuthEnabled(c *gin.Context, labels types.Labels) (bool, error) { func (auth *Auth) AuthEnabled(uri string, labels types.Labels) (bool, error) {
// Get headers // If the label is empty, auth is enabled
uri := c.Request.Header.Get("X-Forwarded-Uri")
// Check if the allowed label is empty
if labels.Allowed == "" { if labels.Allowed == "" {
// Auth enabled
return true, nil return true, nil
} }
@@ -334,13 +372,12 @@ func (auth *Auth) AuthEnabled(c *gin.Context, labels types.Labels) (bool, error)
// If there is an error, invalid regex, auth enabled // If there is an error, invalid regex, auth enabled
if err != nil { if err != nil {
log.Warn().Err(err).Msg("Invalid regex") log.Error().Err(err).Msg("Invalid regex")
return true, err return true, err
} }
// Check if the uri matches the regex // If the regex matches the URI, auth is not enabled
if regex.MatchString(uri) { if regex.MatchString(uri) {
// Auth disabled
return false, nil return false, nil
} }
@@ -349,30 +386,22 @@ func (auth *Auth) AuthEnabled(c *gin.Context, labels types.Labels) (bool, error)
} }
func (auth *Auth) GetBasicAuth(c *gin.Context) *types.User { func (auth *Auth) GetBasicAuth(c *gin.Context) *types.User {
// Get the Authorization header
username, password, ok := c.Request.BasicAuth() username, password, ok := c.Request.BasicAuth()
// If not ok, return an empty user
if !ok { if !ok {
return nil return nil
} }
// Return the user
return &types.User{ return &types.User{
Username: username, Username: username,
Password: password, Password: password,
} }
} }
func (auth *Auth) CheckIP(c *gin.Context, labels types.Labels) bool { func (auth *Auth) CheckIP(labels types.Labels, ip string) bool {
// Get the IP address from the request
ip := c.ClientIP()
// Check if the IP is in block list // Check if the IP is in block list
for _, blocked := range labels.IP.Block { for _, blocked := range labels.IP.Block {
res, err := utils.FilterIP(blocked, ip) res, err := utils.FilterIP(blocked, ip)
if err != nil { if err != nil {
log.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list") log.Error().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
continue continue
} }
if res { if res {
@@ -385,7 +414,7 @@ func (auth *Auth) CheckIP(c *gin.Context, labels types.Labels) bool {
for _, allowed := range labels.IP.Allow { for _, allowed := range labels.IP.Allow {
res, err := utils.FilterIP(allowed, ip) res, err := utils.FilterIP(allowed, ip)
if err != nil { if err != nil {
log.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list") log.Error().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
continue continue
} }
if res { if res {
@@ -401,6 +430,23 @@ func (auth *Auth) CheckIP(c *gin.Context, labels types.Labels) bool {
} }
log.Debug().Str("ip", ip).Msg("IP not in allow or block list, allowing by default") log.Debug().Str("ip", ip).Msg("IP not in allow or block list, allowing by default")
return true return true
} }
func (auth *Auth) BypassedIP(labels types.Labels, ip string) bool {
// For every IP in the bypass list, check if the IP matches
for _, bypassed := range labels.IP.Bypass {
res, err := utils.FilterIP(bypassed, ip)
if err != nil {
log.Error().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
continue
}
if res {
log.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, allowing access")
return true
}
}
log.Debug().Str("ip", ip).Msg("IP not in bypass list, continuing with authentication")
return false
}

View File

@@ -4,7 +4,6 @@ import (
"testing" "testing"
"time" "time"
"tinyauth/internal/auth" "tinyauth/internal/auth"
"tinyauth/internal/docker"
"tinyauth/internal/types" "tinyauth/internal/types"
) )
@@ -18,7 +17,7 @@ func TestLoginRateLimiting(t *testing.T) {
// Initialize a new auth service with 3 max retries and 5 seconds timeout // Initialize a new auth service with 3 max retries and 5 seconds timeout
config.LoginMaxRetries = 3 config.LoginMaxRetries = 3
config.LoginTimeout = 5 config.LoginTimeout = 5
authService := auth.NewAuth(config, &docker.Docker{}) authService := auth.NewAuth(config, nil, nil)
// Test identifier // Test identifier
identifier := "test_user" identifier := "test_user"
@@ -62,7 +61,7 @@ func TestLoginRateLimiting(t *testing.T) {
// Reinitialize auth service with a shorter timeout for testing // Reinitialize auth service with a shorter timeout for testing
config.LoginTimeout = 1 config.LoginTimeout = 1
config.LoginMaxRetries = 3 config.LoginMaxRetries = 3
authService = auth.NewAuth(config, &docker.Docker{}) authService = auth.NewAuth(config, nil, nil)
// Add enough failed attempts to lock the account // Add enough failed attempts to lock the account
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
@@ -87,7 +86,7 @@ func TestLoginRateLimiting(t *testing.T) {
t.Log("Testing disabled rate limiting") t.Log("Testing disabled rate limiting")
config.LoginMaxRetries = 0 config.LoginMaxRetries = 0
config.LoginTimeout = 0 config.LoginTimeout = 0
authService = auth.NewAuth(config, &docker.Docker{}) authService = auth.NewAuth(config, nil, nil)
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
authService.RecordLoginAttempt(identifier, false) authService.RecordLoginAttempt(identifier, false)
@@ -103,7 +102,7 @@ func TestConcurrentLoginAttempts(t *testing.T) {
// Initialize a new auth service with 2 max retries and 5 seconds timeout // Initialize a new auth service with 2 max retries and 5 seconds timeout
config.LoginMaxRetries = 2 config.LoginMaxRetries = 2
config.LoginTimeout = 5 config.LoginTimeout = 5
authService := auth.NewAuth(config, &docker.Docker{}) authService := auth.NewAuth(config, nil, nil)
// Test multiple identifiers // Test multiple identifiers
identifiers := []string{"user1", "user2", "user3"} identifiers := []string{"user1", "user2", "user3"}

View File

@@ -1,11 +1,11 @@
package constants package constants
// Claims are the OIDC supported claims (including preferd username for some reason) // Claims are the OIDC supported claims (prefered username is included for convinience)
type Claims struct { type Claims struct {
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
PreferredUsername string `json:"preferred_username"` PreferredUsername string `json:"preferred_username"`
Groups []string `json:"groups"` Groups any `json:"groups"`
} }
// Version information // Version information
@@ -13,7 +13,7 @@ var Version = "development"
var CommitHash = "n/a" var CommitHash = "n/a"
var BuildTimestamp = "n/a" var BuildTimestamp = "n/a"
// Cookie names // Base cookie names
var SessionCookieName = "tinyauth-session" var SessionCookieName = "tinyauth-session"
var CsrfCookieName = "tinyauth-csrf" var CsrfCookieName = "tinyauth-csrf"
var RedirectCookieName = "tinyauth-redirect" var RedirectCookieName = "tinyauth-redirect"

View File

@@ -11,121 +11,92 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
func NewDocker() *Docker {
return &Docker{}
}
type Docker struct { type Docker struct {
Client *client.Client Client *client.Client
Context context.Context Context context.Context
} }
func (docker *Docker) Init() error { func NewDocker() (*Docker, error) {
// Create a new docker client
client, err := client.NewClientWithOpts(client.FromEnv) client, err := client.NewClientWithOpts(client.FromEnv)
// Check if there was an error
if err != nil {
return err
}
// Create the context
docker.Context = context.Background()
// Negotiate API version
client.NegotiateAPIVersion(docker.Context)
// Set client
docker.Client = client
// Done
return nil
}
func (docker *Docker) GetContainers() ([]container.Summary, error) {
// Get the list of containers
containers, err := docker.Client.ContainerList(docker.Context, container.ListOptions{})
// Check if there was an error
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Return the containers ctx := context.Background()
client.NegotiateAPIVersion(ctx)
return &Docker{
Client: client,
Context: ctx,
}, nil
}
func (docker *Docker) GetContainers() ([]container.Summary, error) {
containers, err := docker.Client.ContainerList(docker.Context, container.ListOptions{})
if err != nil {
return nil, err
}
return containers, nil return containers, nil
} }
func (docker *Docker) InspectContainer(containerId string) (container.InspectResponse, error) { func (docker *Docker) InspectContainer(containerId string) (container.InspectResponse, error) {
// Inspect the container
inspect, err := docker.Client.ContainerInspect(docker.Context, containerId) inspect, err := docker.Client.ContainerInspect(docker.Context, containerId)
// Check if there was an error
if err != nil { if err != nil {
return container.InspectResponse{}, err return container.InspectResponse{}, err
} }
// Return the inspect
return inspect, nil return inspect, nil
} }
func (docker *Docker) DockerConnected() bool { func (docker *Docker) DockerConnected() bool {
// Ping the docker client if there is an error it is not connected
_, err := docker.Client.Ping(docker.Context) _, err := docker.Client.Ping(docker.Context)
return err == nil return err == nil
} }
func (docker *Docker) GetLabels(id string, domain string) (types.Labels, error) { func (docker *Docker) GetLabels(app string, domain string) (types.Labels, error) {
// Check if we have access to the Docker API
isConnected := docker.DockerConnected() isConnected := docker.DockerConnected()
// If we don't have access, return an empty struct
if !isConnected { if !isConnected {
log.Debug().Msg("Docker not connected, returning empty labels") log.Debug().Msg("Docker not connected, returning empty labels")
return types.Labels{}, nil return types.Labels{}, nil
} }
// Get the containers
log.Debug().Msg("Getting containers") log.Debug().Msg("Getting containers")
containers, err := docker.GetContainers() containers, err := docker.GetContainers()
// If there is an error, return false
if err != nil { if err != nil {
log.Error().Err(err).Msg("Error getting containers") log.Error().Err(err).Msg("Error getting containers")
return types.Labels{}, err return types.Labels{}, err
} }
// Loop through the containers
for _, container := range containers { for _, container := range containers {
// Inspect the container
inspect, err := docker.InspectContainer(container.ID) inspect, err := docker.InspectContainer(container.ID)
// Check if there was an error
if err != nil { if err != nil {
log.Warn().Str("id", container.ID).Err(err).Msg("Error inspecting container, skipping") log.Warn().Str("id", container.ID).Err(err).Msg("Error inspecting container, skipping")
continue continue
} }
// Get the labels
log.Debug().Str("id", inspect.ID).Msg("Getting labels for container") log.Debug().Str("id", inspect.ID).Msg("Getting labels for container")
labels, err := utils.GetLabels(inspect.Config.Labels) labels, err := utils.GetLabels(inspect.Config.Labels)
// Check if there was an error
if err != nil { if err != nil {
log.Warn().Str("id", container.ID).Err(err).Msg("Error getting container labels, skipping") log.Warn().Str("id", container.ID).Err(err).Msg("Error getting container labels, skipping")
continue continue
} }
// Check if the labels match the id or the domain // Check if the container matches the ID or domain
if strings.TrimPrefix(inspect.Name, "/") == id || labels.Domain == domain { for _, lDomain := range labels.Domain {
log.Debug().Str("id", inspect.ID).Msg("Found matching container") if lDomain == domain {
log.Debug().Str("id", inspect.ID).Msg("Found matching container by domain")
return labels, nil
}
}
if strings.TrimPrefix(inspect.Name, "/") == app {
log.Debug().Str("id", inspect.ID).Msg("Found matching container by name")
return labels, nil return labels, nil
} }
} }
log.Debug().Msg("No matching container found, returning empty labels") log.Debug().Msg("No matching container found, returning empty labels")
// If no matching container is found, return empty labels
return types.Labels{}, nil return types.Labels{}, nil
} }

View File

@@ -0,0 +1,64 @@
package handlers
import (
"tinyauth/internal/types"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
func (h *Handlers) AppContextHandler(c *gin.Context) {
log.Debug().Msg("Getting app context")
// Get configured providers
configuredProviders := h.Providers.GetConfiguredProviders()
// We have username/password configured so add it to our providers
if h.Auth.UserAuthConfigured() {
configuredProviders = append(configuredProviders, "username")
}
// Return app context
appContext := types.AppContext{
Status: 200,
Message: "OK",
ConfiguredProviders: configuredProviders,
DisableContinue: h.Config.DisableContinue,
Title: h.Config.Title,
GenericName: h.Config.GenericName,
Domain: h.Config.Domain,
ForgotPasswordMessage: h.Config.ForgotPasswordMessage,
BackgroundImage: h.Config.BackgroundImage,
OAuthAutoRedirect: h.Config.OAuthAutoRedirect,
}
c.JSON(200, appContext)
}
func (h *Handlers) UserContextHandler(c *gin.Context) {
log.Debug().Msg("Getting user context")
// Create user context using hooks
userContext := h.Hooks.UseUserContext(c)
userContextResponse := types.UserContextResponse{
Status: 200,
IsLoggedIn: userContext.IsLoggedIn,
Username: userContext.Username,
Name: userContext.Name,
Email: userContext.Email,
Provider: userContext.Provider,
Oauth: userContext.OAuth,
TotpPending: userContext.TotpPending,
}
// If we are not logged in we set the status to 401 else we set it to 200
if !userContext.IsLoggedIn {
log.Debug().Msg("Unauthorized")
userContextResponse.Message = "Unauthorized"
} else {
log.Debug().Interface("userContext", userContext).Msg("Authenticated")
userContextResponse.Message = "Authenticated"
}
c.JSON(200, userContextResponse)
}

View File

@@ -1,23 +1,23 @@
package handlers package handlers
import ( import (
"fmt"
"net/http"
"strings"
"time"
"tinyauth/internal/auth" "tinyauth/internal/auth"
"tinyauth/internal/docker" "tinyauth/internal/docker"
"tinyauth/internal/hooks" "tinyauth/internal/hooks"
"tinyauth/internal/providers" "tinyauth/internal/providers"
"tinyauth/internal/types" "tinyauth/internal/types"
"tinyauth/internal/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog/log"
) )
type Handlers struct {
Config types.HandlersConfig
Auth *auth.Auth
Hooks *hooks.Hooks
Providers *providers.Providers
Docker *docker.Docker
}
func NewHandlers(config types.HandlersConfig, auth *auth.Auth, hooks *hooks.Hooks, providers *providers.Providers, docker *docker.Docker) *Handlers { func NewHandlers(config types.HandlersConfig, auth *auth.Auth, hooks *hooks.Hooks, providers *providers.Providers, docker *docker.Docker) *Handlers {
return &Handlers{ return &Handlers{
Config: config, Config: config,
@@ -28,793 +28,6 @@ func NewHandlers(config types.HandlersConfig, auth *auth.Auth, hooks *hooks.Hook
} }
} }
type Handlers struct {
Config types.HandlersConfig
Auth *auth.Auth
Hooks *hooks.Hooks
Providers *providers.Providers
Docker *docker.Docker
}
func (h *Handlers) AuthHandler(c *gin.Context) {
// Create struct for proxy
var proxy types.Proxy
// Bind URI
err := c.BindUri(&proxy)
// Handle error
if err != nil {
log.Error().Err(err).Msg("Failed to bind URI")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
// Check if the request is coming from a browser (tools like curl/bruno use */* and they don't include the text/html)
isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html")
if isBrowser {
log.Debug().Msg("Request is most likely coming from a browser")
} else {
log.Debug().Msg("Request is most likely not coming from a browser")
}
log.Debug().Interface("proxy", proxy.Proxy).Msg("Got proxy")
// Get headers
uri := c.Request.Header.Get("X-Forwarded-Uri")
proto := c.Request.Header.Get("X-Forwarded-Proto")
host := c.Request.Header.Get("X-Forwarded-Host")
// Remove the port from the host if it exists
hostPortless := strings.Split(host, ":")[0] // *lol*
// Get the id
id := strings.Split(hostPortless, ".")[0]
// Get the container labels
labels, err := h.Docker.GetLabels(id, hostPortless)
log.Debug().Interface("labels", labels).Msg("Got labels")
// Check if there was an error
if err != nil {
log.Error().Err(err).Msg("Failed to get container labels")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
// Check if the IP is allowed/blocked
ip := c.ClientIP()
if !h.Auth.CheckIP(c, labels) {
log.Warn().Str("ip", ip).Msg("IP not allowed")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(403, gin.H{
"status": 403,
"message": "Forbidden",
})
return
}
values := types.UnauthorizedQuery{
Resource: strings.Split(host, ".")[0],
IP: ip,
}
// Build query
queries, err := query.Values(values)
// Handle error
if err != nil {
log.Error().Err(err).Msg("Failed to build queries")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
return
}
// Check if auth is enabled
authEnabled, err := h.Auth.AuthEnabled(c, labels)
// Check if there was an error
if err != nil {
log.Error().Err(err).Msg("Failed to check if app is allowed")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
// If auth is not enabled, return 200
if !authEnabled {
headersParsed := utils.ParseHeaders(labels.Headers)
for key, value := range headersParsed {
log.Debug().Str("key", key).Msg("Setting header")
c.Header(key, value)
}
if labels.Basic.User != "" && labels.Basic.Password != "" {
log.Debug().Str("username", labels.Basic.User).Msg("Setting basic auth headers")
c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Basic.User, labels.Basic.Password)))
}
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
})
return
}
// Get user context
userContext := h.Hooks.UseUserContext(c)
// If we are using basic auth, we need to check if the user has totp and if it does then disable basic auth
if userContext.Provider == "basic" && userContext.TotpEnabled {
log.Warn().Str("username", userContext.Username).Msg("User has totp enabled, disabling basic auth")
userContext.IsLoggedIn = false
}
// Check if user is logged in
if userContext.IsLoggedIn {
log.Debug().Msg("Authenticated")
// Check if user is allowed to access subdomain, if request is nginx.example.com the subdomain (resource) is nginx
appAllowed := h.Auth.ResourceAllowed(c, userContext, labels)
log.Debug().Bool("appAllowed", appAllowed).Msg("Checking if app is allowed")
// The user is not allowed to access the app
if !appAllowed {
log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User not allowed")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Values
values := types.UnauthorizedQuery{
Resource: strings.Split(host, ".")[0],
}
// Use either username or email
if userContext.OAuth {
values.Username = userContext.Email
} else {
values.Username = userContext.Username
}
// Build query
queries, err := query.Values(values)
// Handle error (no need to check for nginx/headers since we are sure we are using caddy/traefik)
if err != nil {
log.Error().Err(err).Msg("Failed to build queries")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
// We are using caddy/traefik so redirect
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
return
}
// Check groups if using OAuth
if userContext.OAuth {
// Check if user is in required groups
groupOk := h.Auth.OAuthGroup(c, userContext, labels)
log.Debug().Bool("groupOk", groupOk).Msg("Checking if user is in required groups")
// The user is not allowed to access the app
if !groupOk {
log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User is not in required groups")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Values
values := types.UnauthorizedQuery{
Resource: strings.Split(host, ".")[0],
GroupErr: true,
}
// Use either username or email
if userContext.OAuth {
values.Username = userContext.Email
} else {
values.Username = userContext.Username
}
// Build query
queries, err := query.Values(values)
// Handle error (no need to check for nginx/headers since we are sure we are using caddy/traefik)
if err != nil {
log.Error().Err(err).Msg("Failed to build queries")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
// We are using caddy/traefik so redirect
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
return
}
}
c.Header("Remote-User", utils.SanitizeHeader(userContext.Username))
c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name))
c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email))
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups))
// Set the rest of the headers
parsedHeaders := utils.ParseHeaders(labels.Headers)
for key, value := range parsedHeaders {
log.Debug().Str("key", key).Msg("Setting header")
c.Header(key, value)
}
// Set basic auth headers if configured
if labels.Basic.User != "" && labels.Basic.Password != "" {
log.Debug().Str("username", labels.Basic.User).Msg("Setting basic auth headers")
c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Basic.User, labels.Basic.Password)))
}
// The user is allowed to access the app
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
})
return
}
// The user is not logged in
log.Debug().Msg("Unauthorized")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
queries, err := query.Values(types.LoginQuery{
RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri),
})
if err != nil {
log.Error().Err(err).Msg("Failed to build queries")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login")
// Redirect to login
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/login?%s", h.Config.AppURL, queries.Encode()))
}
func (h *Handlers) LoginHandler(c *gin.Context) {
// Create login struct
var login types.LoginRequest
// Bind JSON
err := c.BindJSON(&login)
// Handle error
if err != nil {
log.Error().Err(err).Msg("Failed to bind JSON")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
log.Debug().Msg("Got login request")
// Get client IP for rate limiting
clientIP := c.ClientIP()
// Create an identifier for rate limiting (username or IP if username doesn't exist yet)
rateIdentifier := login.Username
if rateIdentifier == "" {
rateIdentifier = clientIP
}
// Check if the account is locked due to too many failed attempts
locked, remainingTime := h.Auth.IsAccountLocked(rateIdentifier)
if locked {
log.Warn().Str("identifier", rateIdentifier).Int("remaining_seconds", remainingTime).Msg("Account is locked due to too many failed login attempts")
c.JSON(429, gin.H{
"status": 429,
"message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remainingTime),
})
return
}
// Get user based on username
user := h.Auth.GetUser(login.Username)
// User does not exist
if user == nil {
log.Debug().Str("username", login.Username).Msg("User not found")
// Record failed login attempt
h.Auth.RecordLoginAttempt(rateIdentifier, false)
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
log.Debug().Msg("Got user")
// Check if password is correct
if !h.Auth.CheckPassword(*user, login.Password) {
log.Debug().Str("username", login.Username).Msg("Password incorrect")
// Record failed login attempt
h.Auth.RecordLoginAttempt(rateIdentifier, false)
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
log.Debug().Msg("Password correct, checking totp")
// Record successful login attempt (will reset failed attempt counter)
h.Auth.RecordLoginAttempt(rateIdentifier, true)
// Check if user has totp enabled
if user.TotpSecret != "" {
log.Debug().Msg("Totp enabled")
// Set totp pending cookie
h.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: login.Username,
Name: utils.Capitalize(login.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(login.Username), h.Config.Domain),
Provider: "username",
TotpPending: true,
})
// Return totp required
c.JSON(200, gin.H{
"status": 200,
"message": "Waiting for totp",
"totpPending": true,
})
// Stop further processing
return
}
// Create session cookie with username as provider
h.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: login.Username,
Name: utils.Capitalize(login.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(login.Username), h.Config.Domain),
Provider: "username",
})
// Return logged in
c.JSON(200, gin.H{
"status": 200,
"message": "Logged in",
"totpPending": false,
})
}
func (h *Handlers) TotpHandler(c *gin.Context) {
// Create totp struct
var totpReq types.TotpRequest
// Bind JSON
err := c.BindJSON(&totpReq)
// Handle error
if err != nil {
log.Error().Err(err).Msg("Failed to bind JSON")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
log.Debug().Msg("Checking totp")
// Get user context
userContext := h.Hooks.UseUserContext(c)
// Check if we have a user
if userContext.Username == "" {
log.Debug().Msg("No user context")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Get user
user := h.Auth.GetUser(userContext.Username)
// Check if user exists
if user == nil {
log.Debug().Msg("User not found")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Check if totp is correct
ok := totp.Validate(totpReq.Code, user.TotpSecret)
// TOTP is incorrect
if !ok {
log.Debug().Msg("Totp incorrect")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
log.Debug().Msg("Totp correct")
// Create session cookie with username as provider
h.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: user.Username,
Name: utils.Capitalize(user.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), h.Config.Domain),
Provider: "username",
})
// Return logged in
c.JSON(200, gin.H{
"status": 200,
"message": "Logged in",
})
}
func (h *Handlers) LogoutHandler(c *gin.Context) {
log.Debug().Msg("Logging out")
// Delete session cookie
h.Auth.DeleteSessionCookie(c)
log.Debug().Msg("Cleaning up redirect cookie")
// Return logged out
c.JSON(200, gin.H{
"status": 200,
"message": "Logged out",
})
}
func (h *Handlers) AppHandler(c *gin.Context) {
log.Debug().Msg("Getting app context")
// Get configured providers
configuredProviders := h.Providers.GetConfiguredProviders()
// We have username/password configured so add it to our providers
if h.Auth.UserAuthConfigured() {
configuredProviders = append(configuredProviders, "username")
}
// Create app context struct
appContext := types.AppContext{
Status: 200,
Message: "OK",
ConfiguredProviders: configuredProviders,
DisableContinue: h.Config.DisableContinue,
Title: h.Config.Title,
GenericName: h.Config.GenericName,
Domain: h.Config.Domain,
ForgotPasswordMessage: h.Config.ForgotPasswordMessage,
BackgroundImage: h.Config.BackgroundImage,
OAuthAutoRedirect: h.Config.OAuthAutoRedirect,
}
// Return app context
c.JSON(200, appContext)
}
func (h *Handlers) UserHandler(c *gin.Context) {
log.Debug().Msg("Getting user context")
// Get user context
userContext := h.Hooks.UseUserContext(c)
// Create user context response
userContextResponse := types.UserContextResponse{
Status: 200,
IsLoggedIn: userContext.IsLoggedIn,
Username: userContext.Username,
Name: userContext.Name,
Email: userContext.Email,
Provider: userContext.Provider,
Oauth: userContext.OAuth,
TotpPending: userContext.TotpPending,
}
// If we are not logged in we set the status to 401 else we set it to 200
if !userContext.IsLoggedIn {
log.Debug().Msg("Unauthorized")
userContextResponse.Message = "Unauthorized"
} else {
log.Debug().Interface("userContext", userContext).Msg("Authenticated")
userContextResponse.Message = "Authenticated"
}
// Return user context
c.JSON(200, userContextResponse)
}
func (h *Handlers) OauthUrlHandler(c *gin.Context) {
// Create struct for OAuth request
var request types.OAuthRequest
// Bind URI
err := c.BindUri(&request)
// Handle error
if err != nil {
log.Error().Err(err).Msg("Failed to bind URI")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
log.Debug().Msg("Got OAuth request")
// Check if provider exists
provider := h.Providers.GetProvider(request.Provider)
// Provider does not exist
if provider == nil {
c.JSON(404, gin.H{
"status": 404,
"message": "Not Found",
})
return
}
log.Debug().Str("provider", request.Provider).Msg("Got provider")
// Create state
state := provider.GenerateState()
// Get auth URL
authURL := provider.GetAuthURL(state)
log.Debug().Msg("Got auth URL")
// Set CSRF cookie
c.SetCookie(h.Config.CsrfCookieName, state, int(time.Hour.Seconds()), "/", "", h.Config.CookieSecure, true)
// Get redirect URI
redirectURI := c.Query("redirect_uri")
// Set redirect cookie if redirect URI is provided
if redirectURI != "" {
log.Debug().Str("redirectURI", redirectURI).Msg("Setting redirect cookie")
c.SetCookie(h.Config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", "", h.Config.CookieSecure, true)
}
// Return auth URL
c.JSON(200, gin.H{
"status": 200,
"message": "OK",
"url": authURL,
})
}
func (h *Handlers) OauthCallbackHandler(c *gin.Context) {
// Create struct for OAuth request
var providerName types.OAuthRequest
// Bind URI
err := c.BindUri(&providerName)
// Handle error
if err != nil {
log.Error().Err(err).Msg("Failed to bind URI")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
log.Debug().Interface("provider", providerName.Provider).Msg("Got provider name")
// Get state
state := c.Query("state")
// Get CSRF cookie
csrfCookie, err := c.Cookie(h.Config.CsrfCookieName)
if err != nil {
log.Debug().Msg("No CSRF cookie")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
log.Debug().Str("csrfCookie", csrfCookie).Msg("Got CSRF cookie")
// Check if CSRF cookie is valid
if csrfCookie != state {
log.Warn().Msg("Invalid CSRF cookie or CSRF cookie does not match with the state")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
// Clean up CSRF cookie
c.SetCookie(h.Config.CsrfCookieName, "", -1, "/", "", h.Config.CookieSecure, true)
// Get code
code := c.Query("code")
log.Debug().Msg("Got code")
// Get provider
provider := h.Providers.GetProvider(providerName.Provider)
log.Debug().Str("provider", providerName.Provider).Msg("Got provider")
// Provider does not exist
if provider == nil {
c.Redirect(http.StatusTemporaryRedirect, "/not-found")
return
}
// Exchange token (authenticates user)
_, err = provider.ExchangeToken(code)
log.Debug().Msg("Got token")
// Handle error
if err != nil {
log.Error().Err(err).Msg("Failed to exchange token")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
// Get user
user, err := h.Providers.GetUser(providerName.Provider)
// Handle error
if err != nil {
log.Error().Msg("Failed to get user")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
log.Debug().Msg("Got user")
// Check that email is not empty
if user.Email == "" {
log.Error().Msg("Email is empty")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
// Email is not whitelisted
if !h.Auth.EmailWhitelisted(user.Email) {
log.Warn().Str("email", user.Email).Msg("Email not whitelisted")
// Build query
queries, err := query.Values(types.UnauthorizedQuery{
Username: user.Email,
})
// Handle error
if err != nil {
log.Error().Err(err).Msg("Failed to build queries")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
// Redirect to unauthorized
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
}
log.Debug().Msg("Email whitelisted")
// Get username
var username string
if user.PreferredUsername != "" {
username = user.PreferredUsername
} else {
username = fmt.Sprintf("%s_%s", strings.Split(user.Email, "@")[0], strings.Split(user.Email, "@")[1])
}
// Get name
var name string
if user.Name != "" {
name = user.Name
} else {
name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1])
}
// Create session cookie (also cleans up redirect cookie)
h.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: username,
Name: name,
Email: user.Email,
Provider: providerName.Provider,
OAuthGroups: strings.Join(user.Groups, ","),
})
// Check if we have a redirect URI
redirectCookie, err := c.Cookie(h.Config.RedirectCookieName)
if err != nil {
log.Debug().Msg("No redirect cookie")
c.Redirect(http.StatusTemporaryRedirect, h.Config.AppURL)
return
}
log.Debug().Str("redirectURI", redirectCookie).Msg("Got redirect URI")
// Build query
queries, err := query.Values(types.LoginQuery{
RedirectURI: redirectCookie,
})
log.Debug().Msg("Got redirect query")
// Handle error
if err != nil {
log.Error().Err(err).Msg("Failed to build queries")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
// Clean up redirect cookie
c.SetCookie(h.Config.RedirectCookieName, "", -1, "/", "", h.Config.CookieSecure, true)
// Redirect to continue with the redirect URI
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/continue?%s", h.Config.AppURL, queries.Encode()))
}
func (h *Handlers) HealthcheckHandler(c *gin.Context) { func (h *Handlers) HealthcheckHandler(c *gin.Context) {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,

View File

@@ -1,4 +1,4 @@
package api_test package handlers_test
import ( import (
"encoding/json" "encoding/json"
@@ -8,24 +8,26 @@ import (
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
"tinyauth/internal/api" "time"
"tinyauth/internal/auth" "tinyauth/internal/auth"
"tinyauth/internal/docker" "tinyauth/internal/docker"
"tinyauth/internal/handlers" "tinyauth/internal/handlers"
"tinyauth/internal/hooks" "tinyauth/internal/hooks"
"tinyauth/internal/providers" "tinyauth/internal/providers"
"tinyauth/internal/server"
"tinyauth/internal/types" "tinyauth/internal/types"
"github.com/magiconair/properties/assert" "github.com/magiconair/properties/assert"
"github.com/pquerna/otp/totp"
) )
// Simple API config for tests // Simple server config
var apiConfig = types.APIConfig{ var serverConfig = types.ServerConfig{
Port: 8080, Port: 8080,
Address: "0.0.0.0", Address: "0.0.0.0",
} }
// Simple handlers config for tests // Simple handlers config
var handlersConfig = types.HandlersConfig{ var handlersConfig = types.HandlersConfig{
AppURL: "http://localhost:8080", AppURL: "http://localhost:8080",
Domain: "localhost", Domain: "localhost",
@@ -33,19 +35,19 @@ var handlersConfig = types.HandlersConfig{
CookieSecure: false, CookieSecure: false,
Title: "Tinyauth", Title: "Tinyauth",
GenericName: "Generic", GenericName: "Generic",
ForgotPasswordMessage: "Some message", ForgotPasswordMessage: "Message",
CsrfCookieName: "tinyauth-csrf", CsrfCookieName: "tinyauth-csrf",
RedirectCookieName: "tinyauth-redirect", RedirectCookieName: "tinyauth-redirect",
BackgroundImage: "https://example.com/image.png", BackgroundImage: "https://example.com/image.png",
OAuthAutoRedirect: "none", OAuthAutoRedirect: "none",
} }
// Simple auth config for tests // Simple auth config
var authConfig = types.AuthConfig{ var authConfig = types.AuthConfig{
Users: types.Users{}, Users: types.Users{},
OauthWhitelist: "", OauthWhitelist: "",
HMACSecret: "super-secret-api-thing-for-test1", HMACSecret: "4bZ9K.*:;zH=,9zG!meUxu.B5-S[7.V.", // Complex on purpose
EncryptionSecret: "super-secret-api-thing-for-test2", EncryptionSecret: "\\:!R(u[Sbv6ZLm.7es)H|OqH4y}0u\\rj",
CookieSecure: false, CookieSecure: false,
SessionExpiry: 3600, SessionExpiry: 3600,
LoginTimeout: 0, LoginTimeout: 0,
@@ -54,7 +56,7 @@ var authConfig = types.AuthConfig{
Domain: "localhost", Domain: "localhost",
} }
// Simple hooks config for tests // Simple hooks config
var hooksConfig = types.HooksConfig{ var hooksConfig = types.HooksConfig{
Domain: "localhost", Domain: "localhost",
} }
@@ -68,145 +70,105 @@ var user = types.User{
Password: "$2a$10$AvGHLTYv3xiRJ0xV9xs3XeVIlkGTygI9nqIamFYB5Xu.5.0UWF7B6", // pass Password: "$2a$10$AvGHLTYv3xiRJ0xV9xs3XeVIlkGTygI9nqIamFYB5Xu.5.0UWF7B6", // pass
} }
// We need all this to be able to test the API // Initialize the server for tests
func getAPI(t *testing.T) *api.API { func getServer(t *testing.T) *server.Server {
// Create docker service // Create services
docker := docker.NewDocker()
// Initialize docker
err := docker.Init()
// Check if there was an error
if err != nil {
t.Fatalf("Failed to initialize docker: %v", err)
}
// Create auth service
authConfig.Users = types.Users{ authConfig.Users = types.Users{
{ {
Username: user.Username, Username: user.Username,
Password: user.Password, Password: user.Password,
TotpSecret: user.TotpSecret,
}, },
} }
auth := auth.NewAuth(authConfig, docker) docker, err := docker.NewDocker()
if err != nil {
// Create providers service t.Fatalf("Failed to create docker client: %v", err)
}
auth := auth.NewAuth(authConfig, nil, nil)
providers := providers.NewProviders(types.OAuthConfig{}) providers := providers.NewProviders(types.OAuthConfig{})
// Initialize providers
providers.Init()
// Create hooks service
hooks := hooks.NewHooks(hooksConfig, auth, providers) hooks := hooks.NewHooks(hooksConfig, auth, providers)
// Create handlers service
handlers := handlers.NewHandlers(handlersConfig, auth, hooks, providers, docker) handlers := handlers.NewHandlers(handlersConfig, auth, hooks, providers, docker)
// Create API // Create server
api := api.NewAPI(apiConfig, handlers) srv, err := server.NewServer(serverConfig, handlers)
if err != nil {
t.Fatalf("Failed to create server: %v", err)
}
// Setup routes return srv
api.Init()
api.SetupRoutes()
return api
} }
// Test login (we will need this for the other tests)
func TestLogin(t *testing.T) { func TestLogin(t *testing.T) {
t.Log("Testing login") t.Log("Testing login")
// Get API srv := getServer(t)
api := getAPI(t)
// Create recorder
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
// Create request
user := types.LoginRequest{ user := types.LoginRequest{
Username: "user", Username: "user",
Password: "pass", Password: "pass",
} }
json, err := json.Marshal(user) json, err := json.Marshal(user)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error marshalling json: %v", err) t.Fatalf("Error marshalling json: %v", err)
} }
// Create request
req, err := http.NewRequest("POST", "/api/login", strings.NewReader(string(json))) req, err := http.NewRequest("POST", "/api/login", strings.NewReader(string(json)))
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error creating request: %v", err) t.Fatalf("Error creating request: %v", err)
} }
// Serve the request srv.Router.ServeHTTP(recorder, req)
api.Router.ServeHTTP(recorder, req)
// Assert
assert.Equal(t, recorder.Code, http.StatusOK) assert.Equal(t, recorder.Code, http.StatusOK)
// Get the cookie cookies := recorder.Result().Cookies()
cookie = recorder.Result().Cookies()[0].Value
// Check if the cookie is set if len(cookies) == 0 {
if cookie == "" {
t.Fatalf("Cookie not set") t.Fatalf("Cookie not set")
} }
// Set the cookie for further tests
cookie = cookies[0].Value
} }
// Test app context
func TestAppContext(t *testing.T) { func TestAppContext(t *testing.T) {
// Refresh the cookie
TestLogin(t)
t.Log("Testing app context") t.Log("Testing app context")
// Get API srv := getServer(t)
api := getAPI(t)
// Create recorder
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
// Create request
req, err := http.NewRequest("GET", "/api/app", nil) req, err := http.NewRequest("GET", "/api/app", nil)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error creating request: %v", err) t.Fatalf("Error creating request: %v", err)
} }
// Set the cookie // Set the cookie from the previous test
req.AddCookie(&http.Cookie{ req.AddCookie(&http.Cookie{
Name: "tinyauth", Name: "tinyauth",
Value: cookie, Value: cookie,
}) })
// Serve the request srv.Router.ServeHTTP(recorder, req)
api.Router.ServeHTTP(recorder, req)
// Assert
assert.Equal(t, recorder.Code, http.StatusOK) assert.Equal(t, recorder.Code, http.StatusOK)
// Read the body of the response
body, err := io.ReadAll(recorder.Body) body, err := io.ReadAll(recorder.Body)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error getting body: %v", err) t.Fatalf("Error getting body: %v", err)
} }
// Unmarshal the body into the user struct
var app types.AppContext var app types.AppContext
err = json.Unmarshal(body, &app) err = json.Unmarshal(body, &app)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error unmarshalling body: %v", err) t.Fatalf("Error unmarshalling body: %v", err)
} }
// Create tests values
expected := types.AppContext{ expected := types.AppContext{
Status: 200, Status: 200,
Message: "OK", Message: "OK",
@@ -214,7 +176,7 @@ func TestAppContext(t *testing.T) {
DisableContinue: false, DisableContinue: false,
Title: "Tinyauth", Title: "Tinyauth",
GenericName: "Generic", GenericName: "Generic",
ForgotPasswordMessage: "Some message", ForgotPasswordMessage: "Message",
BackgroundImage: "https://example.com/image.png", BackgroundImage: "https://example.com/image.png",
OAuthAutoRedirect: "none", OAuthAutoRedirect: "none",
Domain: "localhost", Domain: "localhost",
@@ -226,45 +188,34 @@ func TestAppContext(t *testing.T) {
} }
} }
// Test user context
func TestUserContext(t *testing.T) { func TestUserContext(t *testing.T) {
// Refresh the cookie
TestLogin(t)
t.Log("Testing user context") t.Log("Testing user context")
// Get API srv := getServer(t)
api := getAPI(t)
// Create recorder
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
// Create request
req, err := http.NewRequest("GET", "/api/user", nil) req, err := http.NewRequest("GET", "/api/user", nil)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error creating request: %v", err) t.Fatalf("Error creating request: %v", err)
} }
// Set the cookie
req.AddCookie(&http.Cookie{ req.AddCookie(&http.Cookie{
Name: "tinyauth-session", Name: "tinyauth-session",
Value: cookie, Value: cookie,
}) })
// Serve the request srv.Router.ServeHTTP(recorder, req)
api.Router.ServeHTTP(recorder, req)
// Assert
assert.Equal(t, recorder.Code, http.StatusOK) assert.Equal(t, recorder.Code, http.StatusOK)
// Read the body of the response
body, err := io.ReadAll(recorder.Body) body, err := io.ReadAll(recorder.Body)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error getting body: %v", err) t.Fatalf("Error getting body: %v", err)
} }
// Unmarshal the body into the user struct
type User struct { type User struct {
Username string `json:"username"` Username string `json:"username"`
} }
@@ -272,52 +223,172 @@ func TestUserContext(t *testing.T) {
var user User var user User
err = json.Unmarshal(body, &user) err = json.Unmarshal(body, &user)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error unmarshalling body: %v", err) t.Fatalf("Error unmarshalling body: %v", err)
} }
// We should get the username back // We should get the user back
if user.Username != "user" { if user.Username != "user" {
t.Fatalf("Expected user, got %s", user.Username) t.Fatalf("Expected user, got %s", user.Username)
} }
} }
// Test logout
func TestLogout(t *testing.T) { func TestLogout(t *testing.T) {
// Refresh the cookie
TestLogin(t)
t.Log("Testing logout") t.Log("Testing logout")
// Get API srv := getServer(t)
api := getAPI(t)
// Create recorder
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
// Create request
req, err := http.NewRequest("POST", "/api/logout", nil) req, err := http.NewRequest("POST", "/api/logout", nil)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error creating request: %v", err) t.Fatalf("Error creating request: %v", err)
} }
// Set the cookie
req.AddCookie(&http.Cookie{ req.AddCookie(&http.Cookie{
Name: "tinyauth", Name: "tinyauth-session",
Value: cookie, Value: cookie,
}) })
// Serve the request srv.Router.ServeHTTP(recorder, req)
api.Router.ServeHTTP(recorder, req)
// Assert
assert.Equal(t, recorder.Code, http.StatusOK) assert.Equal(t, recorder.Code, http.StatusOK)
// Check if the cookie is different (means go sessions flushed it) // Check if the cookie is different (means the cookie is gone)
if recorder.Result().Cookies()[0].Value == cookie { if recorder.Result().Cookies()[0].Value == cookie {
t.Fatalf("Cookie not flushed") t.Fatalf("Cookie not flushed")
} }
} }
// TODO: Testing for the oauth stuff func TestAuth(t *testing.T) {
// Refresh the cookie
TestLogin(t)
t.Log("Testing auth endpoint")
srv := getServer(t)
recorder := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/api/auth/traefik", nil)
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
req.Header.Set("Accept", "text/html")
srv.Router.ServeHTTP(recorder, req)
assert.Equal(t, recorder.Code, http.StatusTemporaryRedirect)
recorder = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/auth/traefik", nil)
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
req.AddCookie(&http.Cookie{
Name: "tinyauth-session",
Value: cookie,
})
srv.Router.ServeHTTP(recorder, req)
assert.Equal(t, recorder.Code, http.StatusOK)
recorder = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/auth/nginx", nil)
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
srv.Router.ServeHTTP(recorder, req)
assert.Equal(t, recorder.Code, http.StatusUnauthorized)
recorder = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/auth/nginx", nil)
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
req.AddCookie(&http.Cookie{
Name: "tinyauth-session",
Value: cookie,
})
srv.Router.ServeHTTP(recorder, req)
assert.Equal(t, recorder.Code, http.StatusOK)
}
func TestTOTP(t *testing.T) {
t.Log("Testing TOTP")
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "Tinyauth",
AccountName: user.Username,
})
if err != nil {
t.Fatalf("Failed to generate TOTP secret: %v", err)
}
secret := key.Secret()
user.TotpSecret = secret
srv := getServer(t)
user := types.LoginRequest{
Username: "user",
Password: "pass",
}
loginJson, err := json.Marshal(user)
if err != nil {
t.Fatalf("Error marshalling json: %v", err)
}
recorder := httptest.NewRecorder()
req, err := http.NewRequest("POST", "/api/login", strings.NewReader(string(loginJson)))
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
srv.Router.ServeHTTP(recorder, req)
assert.Equal(t, recorder.Code, http.StatusOK)
// Set the cookie for next test
cookie = recorder.Result().Cookies()[0].Value
code, err := totp.GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("Failed to generate TOTP code: %v", err)
}
totpRequest := types.TotpRequest{
Code: code,
}
totpJson, err := json.Marshal(totpRequest)
if err != nil {
t.Fatalf("Error marshalling TOTP request: %v", err)
}
recorder = httptest.NewRecorder()
req, err = http.NewRequest("POST", "/api/totp", strings.NewReader(string(totpJson)))
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
req.AddCookie(&http.Cookie{
Name: "tinyauth-session",
Value: cookie,
})
srv.Router.ServeHTTP(recorder, req)
assert.Equal(t, recorder.Code, http.StatusOK)
}

223
internal/handlers/oauth.go Normal file
View File

@@ -0,0 +1,223 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"time"
"tinyauth/internal/types"
"tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/rs/zerolog/log"
)
func (h *Handlers) OAuthURLHandler(c *gin.Context) {
var request types.OAuthRequest
err := c.BindUri(&request)
if err != nil {
log.Error().Err(err).Msg("Failed to bind URI")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
log.Debug().Msg("Got OAuth request")
// Check if provider exists
provider := h.Providers.GetProvider(request.Provider)
if provider == nil {
c.JSON(404, gin.H{
"status": 404,
"message": "Not Found",
})
return
}
log.Debug().Str("provider", request.Provider).Msg("Got provider")
// Create state
state := provider.GenerateState()
// Get auth URL
authURL := provider.GetAuthURL(state)
log.Debug().Msg("Got auth URL")
// Set CSRF cookie
c.SetCookie(h.Config.CsrfCookieName, state, int(time.Hour.Seconds()), "/", "", h.Config.CookieSecure, true)
// Get redirect URI
redirectURI := c.Query("redirect_uri")
// Set redirect cookie if redirect URI is provided
if redirectURI != "" {
log.Debug().Str("redirectURI", redirectURI).Msg("Setting redirect cookie")
c.SetCookie(h.Config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", "", h.Config.CookieSecure, true)
}
// Return auth URL
c.JSON(200, gin.H{
"status": 200,
"message": "OK",
"url": authURL,
})
}
func (h *Handlers) OAuthCallbackHandler(c *gin.Context) {
var providerName types.OAuthRequest
err := c.BindUri(&providerName)
if err != nil {
log.Error().Err(err).Msg("Failed to bind URI")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
log.Debug().Interface("provider", providerName.Provider).Msg("Got provider name")
// Get state
state := c.Query("state")
// Get CSRF cookie
csrfCookie, err := c.Cookie(h.Config.CsrfCookieName)
if err != nil {
log.Debug().Msg("No CSRF cookie")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
log.Debug().Str("csrfCookie", csrfCookie).Msg("Got CSRF cookie")
// Check if CSRF cookie is valid
if csrfCookie != state {
log.Warn().Msg("Invalid CSRF cookie or CSRF cookie does not match with the state")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
// Clean up CSRF cookie
c.SetCookie(h.Config.CsrfCookieName, "", -1, "/", "", h.Config.CookieSecure, true)
// Get code
code := c.Query("code")
log.Debug().Msg("Got code")
// Get provider
provider := h.Providers.GetProvider(providerName.Provider)
if provider == nil {
c.Redirect(http.StatusTemporaryRedirect, "/not-found")
return
}
log.Debug().Str("provider", providerName.Provider).Msg("Got provider")
// Exchange token (authenticates user)
_, err = provider.ExchangeToken(code)
if err != nil {
log.Error().Err(err).Msg("Failed to exchange token")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
log.Debug().Msg("Got token")
// Get user
user, err := h.Providers.GetUser(providerName.Provider)
if err != nil {
log.Error().Err(err).Msg("Failed to get user")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
log.Debug().Msg("Got user")
// Check that email is not empty
if user.Email == "" {
log.Error().Msg("Email is empty")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
// Email is not whitelisted
if !h.Auth.EmailWhitelisted(user.Email) {
log.Warn().Str("email", user.Email).Msg("Email not whitelisted")
queries, err := query.Values(types.UnauthorizedQuery{
Username: user.Email,
})
if err != nil {
log.Error().Err(err).Msg("Failed to build queries")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
}
log.Debug().Msg("Email whitelisted")
// Get username
var username string
if user.PreferredUsername != "" {
username = user.PreferredUsername
} else {
username = fmt.Sprintf("%s_%s", strings.Split(user.Email, "@")[0], strings.Split(user.Email, "@")[1])
}
// Get name
var name string
if user.Name != "" {
name = user.Name
} else {
name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1])
}
// Create session cookie
h.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: username,
Name: name,
Email: user.Email,
Provider: providerName.Provider,
OAuthGroups: utils.CoalesceToString(user.Groups),
})
// Check if we have a redirect URI
redirectCookie, err := c.Cookie(h.Config.RedirectCookieName)
if err != nil {
log.Debug().Msg("No redirect cookie")
c.Redirect(http.StatusTemporaryRedirect, h.Config.AppURL)
return
}
log.Debug().Str("redirectURI", redirectCookie).Msg("Got redirect URI")
queries, err := query.Values(types.LoginQuery{
RedirectURI: redirectCookie,
})
if err != nil {
log.Error().Err(err).Msg("Failed to build queries")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
log.Debug().Msg("Got redirect query")
// Clean up redirect cookie
c.SetCookie(h.Config.RedirectCookieName, "", -1, "/", "", h.Config.CookieSecure, true)
// Redirect to continue with the redirect URI
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/continue?%s", h.Config.AppURL, queries.Encode()))
}

282
internal/handlers/proxy.go Normal file
View File

@@ -0,0 +1,282 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"tinyauth/internal/types"
"tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/rs/zerolog/log"
)
func (h *Handlers) ProxyHandler(c *gin.Context) {
var proxy types.Proxy
err := c.BindUri(&proxy)
if err != nil {
log.Error().Err(err).Msg("Failed to bind URI")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
// Check if the request is coming from a browser (tools like curl/bruno use */* and they don't include the text/html)
isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html")
if isBrowser {
log.Debug().Msg("Request is most likely coming from a browser")
} else {
log.Debug().Msg("Request is most likely not coming from a browser")
}
log.Debug().Interface("proxy", proxy.Proxy).Msg("Got proxy")
uri := c.Request.Header.Get("X-Forwarded-Uri")
proto := c.Request.Header.Get("X-Forwarded-Proto")
host := c.Request.Header.Get("X-Forwarded-Host")
hostPortless := strings.Split(host, ":")[0] // *lol*
id := strings.Split(hostPortless, ".")[0]
labels, err := h.Docker.GetLabels(id, hostPortless)
if err != nil {
log.Error().Err(err).Msg("Failed to get container labels")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
log.Debug().Interface("labels", labels).Msg("Got labels")
ip := c.ClientIP()
if h.Auth.BypassedIP(labels, ip) {
c.Header("Authorization", c.Request.Header.Get("Authorization"))
headersParsed := utils.ParseHeaders(labels.Headers)
for key, value := range headersParsed {
log.Debug().Str("key", key).Msg("Setting header")
c.Header(key, value)
}
if labels.Basic.Username != "" && utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File) != "" {
log.Debug().Str("username", labels.Basic.Username).Msg("Setting basic auth headers")
c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Basic.Username, utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File))))
}
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
})
return
}
if !h.Auth.CheckIP(labels, ip) {
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(403, gin.H{
"status": 403,
"message": "Forbidden",
})
return
}
values := types.UnauthorizedQuery{
Resource: strings.Split(host, ".")[0],
IP: ip,
}
queries, err := query.Values(values)
if err != nil {
log.Error().Err(err).Msg("Failed to build queries")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
return
}
authEnabled, err := h.Auth.AuthEnabled(uri, labels)
if err != nil {
log.Error().Err(err).Msg("Failed to check if app is allowed")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
if !authEnabled {
c.Header("Authorization", c.Request.Header.Get("Authorization"))
headersParsed := utils.ParseHeaders(labels.Headers)
for key, value := range headersParsed {
log.Debug().Str("key", key).Msg("Setting header")
c.Header(key, value)
}
if labels.Basic.Username != "" && utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File) != "" {
log.Debug().Str("username", labels.Basic.Username).Msg("Setting basic auth headers")
c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Basic.Username, utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File))))
}
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
})
return
}
userContext := h.Hooks.UseUserContext(c)
// If we are using basic auth, we need to check if the user has totp and if it does then disable basic auth
if userContext.Provider == "basic" && userContext.TotpEnabled {
log.Warn().Str("username", userContext.Username).Msg("User has totp enabled, disabling basic auth")
userContext.IsLoggedIn = false
}
if userContext.IsLoggedIn {
log.Debug().Msg("Authenticated")
// Check if user is allowed to access subdomain, if request is nginx.example.com the subdomain (resource) is nginx
appAllowed := h.Auth.ResourceAllowed(c, userContext, labels)
log.Debug().Bool("appAllowed", appAllowed).Msg("Checking if app is allowed")
if !appAllowed {
log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User not allowed")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
values := types.UnauthorizedQuery{
Resource: strings.Split(host, ".")[0],
}
if userContext.OAuth {
values.Username = userContext.Email
} else {
values.Username = userContext.Username
}
queries, err := query.Values(values)
if err != nil {
log.Error().Err(err).Msg("Failed to build queries")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
return
}
if userContext.OAuth {
groupOk := h.Auth.OAuthGroup(c, userContext, labels)
log.Debug().Bool("groupOk", groupOk).Msg("Checking if user is in required groups")
if !groupOk {
log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User is not in required groups")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
values := types.UnauthorizedQuery{
Resource: strings.Split(host, ".")[0],
GroupErr: true,
}
if userContext.OAuth {
values.Username = userContext.Email
} else {
values.Username = userContext.Username
}
queries, err := query.Values(values)
if err != nil {
log.Error().Err(err).Msg("Failed to build queries")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
return
}
}
c.Header("Authorization", c.Request.Header.Get("Authorization"))
c.Header("Remote-User", utils.SanitizeHeader(userContext.Username))
c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name))
c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email))
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups))
parsedHeaders := utils.ParseHeaders(labels.Headers)
for key, value := range parsedHeaders {
log.Debug().Str("key", key).Msg("Setting header")
c.Header(key, value)
}
if labels.Basic.Username != "" && utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File) != "" {
log.Debug().Str("username", labels.Basic.Username).Msg("Setting basic auth headers")
c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Basic.Username, utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File))))
}
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
})
return
}
// The user is not logged in
log.Debug().Msg("Unauthorized")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
queries, err := query.Values(types.LoginQuery{
RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri),
})
if err != nil {
log.Error().Err(err).Msg("Failed to build queries")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/login?%s", h.Config.AppURL, queries.Encode()))
}

197
internal/handlers/user.go Normal file
View File

@@ -0,0 +1,197 @@
package handlers
import (
"fmt"
"strings"
"tinyauth/internal/types"
"tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog/log"
)
func (h *Handlers) LoginHandler(c *gin.Context) {
var login types.LoginRequest
err := c.BindJSON(&login)
if err != nil {
log.Error().Err(err).Msg("Failed to bind JSON")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
log.Debug().Msg("Got login request")
clientIP := c.ClientIP()
// Create an identifier for rate limiting (username or IP if username doesn't exist yet)
rateIdentifier := login.Username
if rateIdentifier == "" {
rateIdentifier = clientIP
}
// Check if the account is locked due to too many failed attempts
locked, remainingTime := h.Auth.IsAccountLocked(rateIdentifier)
if locked {
log.Warn().Str("identifier", rateIdentifier).Int("remaining_seconds", remainingTime).Msg("Account is locked due to too many failed login attempts")
c.JSON(429, gin.H{
"status": 429,
"message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remainingTime),
})
return
}
// Search for a user based on username
log.Debug().Interface("username", login.Username).Msg("Searching for user")
userSearch := h.Auth.SearchUser(login.Username)
// User does not exist
if userSearch.Type == "" {
log.Debug().Str("username", login.Username).Msg("User not found")
// Record failed login attempt
h.Auth.RecordLoginAttempt(rateIdentifier, false)
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
log.Debug().Msg("Got user")
// Check if password is correct
if !h.Auth.VerifyUser(userSearch, login.Password) {
log.Debug().Str("username", login.Username).Msg("Password incorrect")
// Record failed login attempt
h.Auth.RecordLoginAttempt(rateIdentifier, false)
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
log.Debug().Msg("Password correct, checking totp")
// Record successful login attempt (will reset failed attempt counter)
h.Auth.RecordLoginAttempt(rateIdentifier, true)
// Check if user is using TOTP
if userSearch.Type == "local" {
// Get local user
localUser := h.Auth.GetLocalUser(login.Username)
// Check if TOTP is enabled
if localUser.TotpSecret != "" {
log.Debug().Msg("Totp enabled")
// Set totp pending cookie
h.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: login.Username,
Name: utils.Capitalize(login.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(login.Username), h.Config.Domain),
Provider: "username",
TotpPending: true,
})
// Return totp required
c.JSON(200, gin.H{
"status": 200,
"message": "Waiting for totp",
"totpPending": true,
})
return
}
}
// Create session cookie with username as provider
h.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: login.Username,
Name: utils.Capitalize(login.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(login.Username), h.Config.Domain),
Provider: "username",
})
// Return logged in
c.JSON(200, gin.H{
"status": 200,
"message": "Logged in",
"totpPending": false,
})
}
func (h *Handlers) TOTPHandler(c *gin.Context) {
var totpReq types.TotpRequest
err := c.BindJSON(&totpReq)
if err != nil {
log.Error().Err(err).Msg("Failed to bind JSON")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
log.Debug().Msg("Checking totp")
// Get user context
userContext := h.Hooks.UseUserContext(c)
// Check if we have a user
if userContext.Username == "" {
log.Debug().Msg("No user context")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Get user
user := h.Auth.GetLocalUser(userContext.Username)
// Check if totp is correct
ok := totp.Validate(totpReq.Code, user.TotpSecret)
if !ok {
log.Debug().Msg("Totp incorrect")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
log.Debug().Msg("Totp correct")
// Create session cookie with username as provider
h.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: user.Username,
Name: utils.Capitalize(user.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), h.Config.Domain),
Provider: "username",
})
// Return logged in
c.JSON(200, gin.H{
"status": 200,
"message": "Logged in",
})
}
func (h *Handlers) LogoutHandler(c *gin.Context) {
log.Debug().Msg("Cleaning up redirect cookie")
h.Auth.DeleteSessionCookie(c)
c.JSON(200, gin.H{
"status": 200,
"message": "Logged out",
})
}

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"tinyauth/internal/auth" "tinyauth/internal/auth"
"tinyauth/internal/oauth"
"tinyauth/internal/providers" "tinyauth/internal/providers"
"tinyauth/internal/types" "tinyauth/internal/types"
"tinyauth/internal/utils" "tinyauth/internal/utils"
@@ -12,6 +13,12 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
type Hooks struct {
Config types.HooksConfig
Auth *auth.Auth
Providers *providers.Providers
}
func NewHooks(config types.HooksConfig, auth *auth.Auth, providers *providers.Providers) *Hooks { func NewHooks(config types.HooksConfig, auth *auth.Auth, providers *providers.Providers) *Hooks {
return &Hooks{ return &Hooks{
Config: config, Config: config,
@@ -20,58 +27,17 @@ func NewHooks(config types.HooksConfig, auth *auth.Auth, providers *providers.Pr
} }
} }
type Hooks struct {
Config types.HooksConfig
Auth *auth.Auth
Providers *providers.Providers
}
func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext { func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
// Get session cookie and basic auth
cookie, err := hooks.Auth.GetSessionCookie(c) cookie, err := hooks.Auth.GetSessionCookie(c)
basic := hooks.Auth.GetBasicAuth(c) var provider *oauth.OAuth
// Check if basic auth is set
if basic != nil {
log.Debug().Msg("Got basic auth")
// Get user
user := hooks.Auth.GetUser(basic.Username)
// Check we have a user
if user == nil {
log.Error().Str("username", basic.Username).Msg("User does not exist")
// Return empty context
return types.UserContext{}
}
// Check if the user has a correct password
if hooks.Auth.CheckPassword(*user, basic.Password) {
// Return user context since we are logged in with basic auth
return types.UserContext{
Username: basic.Username,
Name: utils.Capitalize(basic.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), hooks.Config.Domain),
IsLoggedIn: true,
Provider: "basic",
TotpEnabled: user.TotpSecret != "",
}
}
}
// Check cookie error after basic auth
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to get session cookie") log.Error().Err(err).Msg("Failed to get session cookie")
// Return empty context goto basic
return types.UserContext{}
} }
// Check if session cookie has totp pending
if cookie.TotpPending { if cookie.TotpPending {
log.Debug().Msg("Totp pending") log.Debug().Msg("Totp pending")
// Return empty context since we are pending totp
return types.UserContext{ return types.UserContext{
Username: cookie.Username, Username: cookie.Username,
Name: cookie.Name, Name: cookie.Name,
@@ -81,48 +47,42 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
} }
} }
// Check if session cookie is username/password auth
if cookie.Provider == "username" { if cookie.Provider == "username" {
log.Debug().Msg("Provider is username") log.Debug().Msg("Provider is username")
// Check if user exists userSearch := hooks.Auth.SearchUser(cookie.Username)
if hooks.Auth.GetUser(cookie.Username) != nil {
log.Debug().Msg("User exists")
// It exists so we are logged in if userSearch.Type == "unknown" {
return types.UserContext{ log.Warn().Str("username", cookie.Username).Msg("User does not exist")
Username: cookie.Username, goto basic
Name: cookie.Name, }
Email: cookie.Email,
IsLoggedIn: true, log.Debug().Str("type", userSearch.Type).Msg("User exists")
Provider: "username",
} return types.UserContext{
Username: cookie.Username,
Name: cookie.Name,
Email: cookie.Email,
IsLoggedIn: true,
Provider: "username",
} }
} }
log.Debug().Msg("Provider is not username") log.Debug().Msg("Provider is not username")
// The provider is not username so we need to check if it is an oauth provider provider = hooks.Providers.GetProvider(cookie.Provider)
provider := hooks.Providers.GetProvider(cookie.Provider)
// If we have a provider with this name
if provider != nil { if provider != nil {
log.Debug().Msg("Provider exists") log.Debug().Msg("Provider exists")
// Check if the oauth email is whitelisted
if !hooks.Auth.EmailWhitelisted(cookie.Email) { if !hooks.Auth.EmailWhitelisted(cookie.Email) {
log.Error().Str("email", cookie.Email).Msg("Email is not whitelisted") log.Warn().Str("email", cookie.Email).Msg("Email is not whitelisted")
// It isn't so we delete the cookie and return an empty context
hooks.Auth.DeleteSessionCookie(c) hooks.Auth.DeleteSessionCookie(c)
goto basic
// Return empty context
return types.UserContext{}
} }
log.Debug().Msg("Email is whitelisted") log.Debug().Msg("Email is whitelisted")
// Return user context since we are logged in with oauth
return types.UserContext{ return types.UserContext{
Username: cookie.Username, Username: cookie.Username,
Name: cookie.Name, Name: cookie.Name,
@@ -134,6 +94,51 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
} }
} }
// Neither basic auth or oauth is set so we return an empty context basic:
log.Debug().Msg("Trying basic auth")
basic := hooks.Auth.GetBasicAuth(c)
if basic != nil {
log.Debug().Msg("Got basic auth")
userSearch := hooks.Auth.SearchUser(basic.Username)
if userSearch.Type == "unkown" {
log.Error().Str("username", basic.Username).Msg("Basic auth user does not exist")
return types.UserContext{}
}
if !hooks.Auth.VerifyUser(userSearch, basic.Password) {
log.Error().Str("username", basic.Username).Msg("Basic auth user password incorrect")
return types.UserContext{}
}
if userSearch.Type == "ldap" {
log.Debug().Msg("User is LDAP")
return types.UserContext{
Username: basic.Username,
Name: utils.Capitalize(basic.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), hooks.Config.Domain),
IsLoggedIn: true,
Provider: "basic",
TotpEnabled: false,
}
}
user := hooks.Auth.GetLocalUser(basic.Username)
return types.UserContext{
Username: basic.Username,
Name: utils.Capitalize(basic.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), hooks.Config.Domain),
IsLoggedIn: true,
Provider: "basic",
TotpEnabled: user.TotpSecret != "",
}
}
return types.UserContext{} return types.UserContext{}
} }

147
internal/ldap/ldap.go Normal file
View File

@@ -0,0 +1,147 @@
package ldap
import (
"context"
"crypto/tls"
"fmt"
"time"
"tinyauth/internal/types"
"github.com/cenkalti/backoff/v5"
ldapgo "github.com/go-ldap/ldap/v3"
"github.com/rs/zerolog/log"
)
type LDAP struct {
Config types.LdapConfig
Conn *ldapgo.Conn
}
func NewLDAP(config types.LdapConfig) (*LDAP, error) {
ldap := &LDAP{
Config: config,
}
_, err := ldap.connect()
if err != nil {
return nil, fmt.Errorf("failed to connect to LDAP server: %w", err)
}
go func() {
for range time.Tick(time.Duration(5) * time.Minute) {
err := ldap.heartbeat()
if err != nil {
log.Error().Err(err).Msg("LDAP connection heartbeat failed")
if reconnectErr := ldap.reconnect(); reconnectErr != nil {
log.Error().Err(reconnectErr).Msg("Failed to reconnect to LDAP server")
continue
}
log.Info().Msg("Successfully reconnected to LDAP server")
}
}
}()
return ldap, nil
}
func (l *LDAP) connect() (*ldapgo.Conn, error) {
log.Debug().Msg("Connecting to LDAP server")
conn, err := ldapgo.DialURL(l.Config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
InsecureSkipVerify: l.Config.Insecure,
MinVersion: tls.VersionTLS12,
}))
if err != nil {
return nil, err
}
log.Debug().Msg("Binding to LDAP server")
err = conn.Bind(l.Config.BindDN, l.Config.BindPassword)
if err != nil {
return nil, err
}
// Set and return the connection
l.Conn = conn
return conn, nil
}
func (l *LDAP) Search(username string) (string, error) {
// Escape the username to prevent LDAP injection
escapedUsername := ldapgo.EscapeFilter(username)
filter := fmt.Sprintf(l.Config.SearchFilter, escapedUsername)
searchRequest := ldapgo.NewSearchRequest(
l.Config.BaseDN,
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
filter,
[]string{"dn"},
nil,
)
searchResult, err := l.Conn.Search(searchRequest)
if err != nil {
return "", err
}
if len(searchResult.Entries) != 1 {
return "", fmt.Errorf("err multiple or no entries found for user %s", username)
}
userDN := searchResult.Entries[0].DN
return userDN, nil
}
func (l *LDAP) Bind(userDN string, password string) error {
err := l.Conn.Bind(userDN, password)
if err != nil {
return err
}
return nil
}
func (l *LDAP) heartbeat() error {
log.Debug().Msg("Performing LDAP connection heartbeat")
searchRequest := ldapgo.NewSearchRequest(
"",
ldapgo.ScopeBaseObject, ldapgo.NeverDerefAliases, 0, 0, false,
"(objectClass=*)",
[]string{},
nil,
)
_, err := l.Conn.Search(searchRequest)
if err != nil {
return err
}
// No error means the connection is alive
return nil
}
func (l *LDAP) reconnect() error {
log.Info().Msg("Reconnecting to LDAP server")
exp := backoff.NewExponentialBackOff()
exp.InitialInterval = 500 * time.Millisecond
exp.RandomizationFactor = 0.1
exp.Multiplier = 1.5
exp.Reset()
operation := func() (*ldapgo.Conn, error) {
l.Conn.Close()
_, err := l.connect()
if err != nil {
return nil, nil
}
return nil, nil
}
_, err := backoff.Retry(context.TODO(), operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))
if err != nil {
return err
}
return nil
}

View File

@@ -10,79 +10,62 @@ import (
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
func NewOAuth(config oauth2.Config, insecureSkipVerify bool) *OAuth {
return &OAuth{
Config: config,
InsecureSkipVerify: insecureSkipVerify,
}
}
type OAuth struct { type OAuth struct {
Config oauth2.Config Config oauth2.Config
Context context.Context Context context.Context
Token *oauth2.Token Token *oauth2.Token
Verifier string Verifier string
InsecureSkipVerify bool
} }
func (oauth *OAuth) Init() { func NewOAuth(config oauth2.Config, insecureSkipVerify bool) *OAuth {
// Create transport with TLS
transport := &http.Transport{ transport := &http.Transport{
TLSClientConfig: &tls.Config{ TLSClientConfig: &tls.Config{
InsecureSkipVerify: oauth.InsecureSkipVerify, InsecureSkipVerify: insecureSkipVerify,
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
}, },
} }
// Create a new context
oauth.Context = context.Background()
// Create the HTTP client with the transport
httpClient := &http.Client{ httpClient := &http.Client{
Transport: transport, Transport: transport,
} }
ctx := context.Background()
// Set the HTTP client in the context // Set the HTTP client in the context
oauth.Context = context.WithValue(oauth.Context, oauth2.HTTPClient, httpClient) ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
// Create the verifier
oauth.Verifier = oauth2.GenerateVerifier() verifier := oauth2.GenerateVerifier()
return &OAuth{
Config: config,
Context: ctx,
Verifier: verifier,
}
} }
func (oauth *OAuth) GetAuthURL(state string) string { func (oauth *OAuth) GetAuthURL(state string) string {
// Return the auth url
return oauth.Config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(oauth.Verifier)) return oauth.Config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(oauth.Verifier))
} }
func (oauth *OAuth) ExchangeToken(code string) (string, error) { func (oauth *OAuth) ExchangeToken(code string) (string, error) {
// Exchange the code for a token
token, err := oauth.Config.Exchange(oauth.Context, code, oauth2.VerifierOption(oauth.Verifier)) token, err := oauth.Config.Exchange(oauth.Context, code, oauth2.VerifierOption(oauth.Verifier))
// Check if there was an error
if err != nil { if err != nil {
return "", err return "", err
} }
// Set the token // Set and return the token
oauth.Token = token oauth.Token = token
// Return the access token
return oauth.Token.AccessToken, nil return oauth.Token.AccessToken, nil
} }
func (oauth *OAuth) GetClient() *http.Client { func (oauth *OAuth) GetClient() *http.Client {
// Return the http client with the token set
return oauth.Config.Client(oauth.Context, oauth.Token) return oauth.Config.Client(oauth.Context, oauth.Token)
} }
func (oauth *OAuth) GenerateState() string { func (oauth *OAuth) GenerateState() string {
// Generate a random state string
b := make([]byte, 128) b := make([]byte, 128)
// Fill the byte slice with random data
rand.Read(b) rand.Read(b)
// Encode the byte slice to a base64 string
state := base64.URLEncoding.EncodeToString(b) state := base64.URLEncoding.EncodeToString(b)
return state return state
} }

View File

@@ -10,41 +10,28 @@ import (
) )
func GetGenericUser(client *http.Client, url string) (constants.Claims, error) { func GetGenericUser(client *http.Client, url string) (constants.Claims, error) {
// Create user struct
var user constants.Claims var user constants.Claims
// Using the oauth client get the user info url
res, err := client.Get(url) res, err := client.Get(url)
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
defer res.Body.Close() defer res.Body.Close()
log.Debug().Msg("Got response from generic provider") log.Debug().Msg("Got response from generic provider")
// Read the body of the response
body, err := io.ReadAll(res.Body) body, err := io.ReadAll(res.Body)
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
log.Debug().Msg("Read body from generic provider") log.Debug().Msg("Read body from generic provider")
// Unmarshal the body into the user struct
err = json.Unmarshal(body, &user) err = json.Unmarshal(body, &user)
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
log.Debug().Msg("Parsed user from generic provider") log.Debug().Interface("user", user).Msg("Parsed user from generic provider")
// Return the user
return user, nil return user, nil
} }

View File

@@ -28,71 +28,48 @@ func GithubScopes() []string {
} }
func GetGithubUser(client *http.Client) (constants.Claims, error) { func GetGithubUser(client *http.Client) (constants.Claims, error) {
// Create user struct
var user constants.Claims var user constants.Claims
// Get the user info from github using the oauth http client
res, err := client.Get("https://api.github.com/user") res, err := client.Get("https://api.github.com/user")
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
defer res.Body.Close() defer res.Body.Close()
log.Debug().Msg("Got user response from github") log.Debug().Msg("Got user response from github")
// Read the body of the response
body, err := io.ReadAll(res.Body) body, err := io.ReadAll(res.Body)
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
log.Debug().Msg("Read user body from github") log.Debug().Msg("Read user body from github")
// Parse the body into a user struct
var userInfo GithubUserInfoResponse var userInfo GithubUserInfoResponse
// Unmarshal the body into the user struct
err = json.Unmarshal(body, &userInfo) err = json.Unmarshal(body, &userInfo)
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
// Get the user emails from github using the oauth http client
res, err = client.Get("https://api.github.com/user/emails") res, err = client.Get("https://api.github.com/user/emails")
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
defer res.Body.Close() defer res.Body.Close()
log.Debug().Msg("Got email response from github") log.Debug().Msg("Got email response from github")
// Read the body of the response
body, err = io.ReadAll(res.Body) body, err = io.ReadAll(res.Body)
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
log.Debug().Msg("Read email body from github") log.Debug().Msg("Read email body from github")
// Parse the body into a user struct
var emails GithubEmailResponse var emails GithubEmailResponse
// Unmarshal the body into the user struct
err = json.Unmarshal(body, &emails) err = json.Unmarshal(body, &emails)
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
@@ -102,28 +79,24 @@ func GetGithubUser(client *http.Client) (constants.Claims, error) {
// Find and return the primary email // Find and return the primary email
for _, email := range emails { for _, email := range emails {
if email.Primary { if email.Primary {
// Set the email then exit
log.Debug().Str("email", email.Email).Msg("Found primary email") log.Debug().Str("email", email.Email).Msg("Found primary email")
user.Email = email.Email user.Email = email.Email
break break
} }
} }
// If no primary email was found, use the first available email
if len(emails) == 0 { if len(emails) == 0 {
return user, errors.New("no emails found") return user, errors.New("no emails found")
} }
// Set the email if it is not set picking the first one // Use first available email if no primary email was found
if user.Email == "" { if user.Email == "" {
log.Warn().Str("email", emails[0].Email).Msg("No primary email found, using first email") log.Warn().Str("email", emails[0].Email).Msg("No primary email found, using first email")
user.Email = emails[0].Email user.Email = emails[0].Email
} }
// Set the username and name
user.PreferredUsername = userInfo.Login user.PreferredUsername = userInfo.Login
user.Name = userInfo.Name user.Name = userInfo.Name
// Return
return user, nil return user, nil
} }

View File

@@ -22,49 +22,35 @@ func GoogleScopes() []string {
} }
func GetGoogleUser(client *http.Client) (constants.Claims, error) { func GetGoogleUser(client *http.Client) (constants.Claims, error) {
// Create user struct
var user constants.Claims var user constants.Claims
// Get the user info from google using the oauth http client
res, err := client.Get("https://www.googleapis.com/userinfo/v2/me") res, err := client.Get("https://www.googleapis.com/userinfo/v2/me")
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
defer res.Body.Close() defer res.Body.Close()
log.Debug().Msg("Got response from google") log.Debug().Msg("Got response from google")
// Read the body of the response
body, err := io.ReadAll(res.Body) body, err := io.ReadAll(res.Body)
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
log.Debug().Msg("Read body from google") log.Debug().Msg("Read body from google")
// Create a new user info struct
var userInfo GoogleUserInfoResponse var userInfo GoogleUserInfoResponse
// Unmarshal the body into the user struct
err = json.Unmarshal(body, &userInfo) err = json.Unmarshal(body, &userInfo)
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
log.Debug().Msg("Parsed user from google") log.Debug().Msg("Parsed user from google")
// Map the user info to the user struct
user.PreferredUsername = strings.Split(userInfo.Email, "@")[0] user.PreferredUsername = strings.Split(userInfo.Email, "@")[0]
user.Name = userInfo.Name user.Name = userInfo.Name
user.Email = userInfo.Email user.Email = userInfo.Email
// Return the user
return user, nil return user, nil
} }

View File

@@ -11,12 +11,6 @@ import (
"golang.org/x/oauth2/endpoints" "golang.org/x/oauth2/endpoints"
) )
func NewProviders(config types.OAuthConfig) *Providers {
return &Providers{
Config: config,
}
}
type Providers struct { type Providers struct {
Config types.OAuthConfig Config types.OAuthConfig
Github *oauth.OAuth Github *oauth.OAuth
@@ -24,64 +18,51 @@ type Providers struct {
Generic *oauth.OAuth Generic *oauth.OAuth
} }
func (providers *Providers) Init() { func NewProviders(config types.OAuthConfig) *Providers {
// If we have a client id and secret for github, initialize the oauth provider providers := &Providers{
if providers.Config.GithubClientId != "" && providers.Config.GithubClientSecret != "" { Config: config,
log.Info().Msg("Initializing Github OAuth") }
// Create a new oauth provider with the github config if config.GithubClientId != "" && config.GithubClientSecret != "" {
log.Info().Msg("Initializing Github OAuth")
providers.Github = oauth.NewOAuth(oauth2.Config{ providers.Github = oauth.NewOAuth(oauth2.Config{
ClientID: providers.Config.GithubClientId, ClientID: config.GithubClientId,
ClientSecret: providers.Config.GithubClientSecret, ClientSecret: config.GithubClientSecret,
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/github", providers.Config.AppURL), RedirectURL: fmt.Sprintf("%s/api/oauth/callback/github", config.AppURL),
Scopes: GithubScopes(), Scopes: GithubScopes(),
Endpoint: endpoints.GitHub, Endpoint: endpoints.GitHub,
}, false) }, false)
// Initialize the oauth provider
providers.Github.Init()
} }
// If we have a client id and secret for google, initialize the oauth provider if config.GoogleClientId != "" && config.GoogleClientSecret != "" {
if providers.Config.GoogleClientId != "" && providers.Config.GoogleClientSecret != "" {
log.Info().Msg("Initializing Google OAuth") log.Info().Msg("Initializing Google OAuth")
// Create a new oauth provider with the google config
providers.Google = oauth.NewOAuth(oauth2.Config{ providers.Google = oauth.NewOAuth(oauth2.Config{
ClientID: providers.Config.GoogleClientId, ClientID: config.GoogleClientId,
ClientSecret: providers.Config.GoogleClientSecret, ClientSecret: config.GoogleClientSecret,
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/google", providers.Config.AppURL), RedirectURL: fmt.Sprintf("%s/api/oauth/callback/google", config.AppURL),
Scopes: GoogleScopes(), Scopes: GoogleScopes(),
Endpoint: endpoints.Google, Endpoint: endpoints.Google,
}, false) }, false)
// Initialize the oauth provider
providers.Google.Init()
} }
// If we have a client id and secret for generic oauth, initialize the oauth provider if config.GenericClientId != "" && config.GenericClientSecret != "" {
if providers.Config.GenericClientId != "" && providers.Config.GenericClientSecret != "" {
log.Info().Msg("Initializing Generic OAuth") log.Info().Msg("Initializing Generic OAuth")
// Create a new oauth provider with the generic config
providers.Generic = oauth.NewOAuth(oauth2.Config{ providers.Generic = oauth.NewOAuth(oauth2.Config{
ClientID: providers.Config.GenericClientId, ClientID: config.GenericClientId,
ClientSecret: providers.Config.GenericClientSecret, ClientSecret: config.GenericClientSecret,
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/generic", providers.Config.AppURL), RedirectURL: fmt.Sprintf("%s/api/oauth/callback/generic", config.AppURL),
Scopes: providers.Config.GenericScopes, Scopes: config.GenericScopes,
Endpoint: oauth2.Endpoint{ Endpoint: oauth2.Endpoint{
AuthURL: providers.Config.GenericAuthURL, AuthURL: config.GenericAuthURL,
TokenURL: providers.Config.GenericTokenURL, TokenURL: config.GenericTokenURL,
}, },
}, providers.Config.GenericSkipSSL) }, config.GenericSkipSSL)
// Initialize the oauth provider
providers.Generic.Init()
} }
return providers
} }
func (providers *Providers) GetProvider(provider string) *oauth.OAuth { func (providers *Providers) GetProvider(provider string) *oauth.OAuth {
// Return the provider based on the provider string
switch provider { switch provider {
case "github": case "github":
return providers.Github return providers.Github
@@ -95,82 +76,63 @@ func (providers *Providers) GetProvider(provider string) *oauth.OAuth {
} }
func (providers *Providers) GetUser(provider string) (constants.Claims, error) { func (providers *Providers) GetUser(provider string) (constants.Claims, error) {
// Create user struct
var user constants.Claims var user constants.Claims
// Get the user from the provider // Get the user from the provider
switch provider { switch provider {
case "github": case "github":
// If the github provider is not configured, return an error
if providers.Github == nil { if providers.Github == nil {
log.Debug().Msg("Github provider not configured") log.Debug().Msg("Github provider not configured")
return user, nil return user, nil
} }
// Get the client from the github provider
client := providers.Github.GetClient() client := providers.Github.GetClient()
log.Debug().Msg("Got client from github") log.Debug().Msg("Got client from github")
// Get the user from the github provider
user, err := GetGithubUser(client) user, err := GetGithubUser(client)
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
log.Debug().Msg("Got user from github") log.Debug().Msg("Got user from github")
// Return the user
return user, nil return user, nil
case "google": case "google":
// If the google provider is not configured, return an error
if providers.Google == nil { if providers.Google == nil {
log.Debug().Msg("Google provider not configured") log.Debug().Msg("Google provider not configured")
return user, nil return user, nil
} }
// Get the client from the google provider
client := providers.Google.GetClient() client := providers.Google.GetClient()
log.Debug().Msg("Got client from google") log.Debug().Msg("Got client from google")
// Get the user from the google provider
user, err := GetGoogleUser(client) user, err := GetGoogleUser(client)
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
log.Debug().Msg("Got user from google") log.Debug().Msg("Got user from google")
// Return the user
return user, nil return user, nil
case "generic": case "generic":
// If the generic provider is not configured, return an error
if providers.Generic == nil { if providers.Generic == nil {
log.Debug().Msg("Generic provider not configured") log.Debug().Msg("Generic provider not configured")
return user, nil return user, nil
} }
// Get the client from the generic provider
client := providers.Generic.GetClient() client := providers.Generic.GetClient()
log.Debug().Msg("Got client from generic") log.Debug().Msg("Got client from generic")
// Get the user from the generic provider
user, err := GetGenericUser(client, providers.Config.GenericUserURL) user, err := GetGenericUser(client, providers.Config.GenericUserURL)
// Check if there was an error
if err != nil { if err != nil {
return user, err return user, err
} }
log.Debug().Msg("Got user from generic") log.Debug().Msg("Got user from generic")
// Return the email
return user, nil return user, nil
default: default:
return user, nil return user, nil
@@ -178,7 +140,6 @@ func (providers *Providers) GetUser(provider string) (constants.Claims, error) {
} }
func (provider *Providers) GetConfiguredProviders() []string { func (provider *Providers) GetConfiguredProviders() []string {
// Create a list of the configured providers
providers := []string{} providers := []string{}
if provider.Github != nil { if provider.Github != nil {
providers = append(providers, "github") providers = append(providers, "github")

130
internal/server/server.go Normal file
View File

@@ -0,0 +1,130 @@
package server
import (
"fmt"
"io/fs"
"net/http"
"os"
"strings"
"time"
"tinyauth/internal/assets"
"tinyauth/internal/handlers"
"tinyauth/internal/types"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
type Server struct {
Config types.ServerConfig
Handlers *handlers.Handlers
Router *gin.Engine
}
var (
loggerSkipPathsPrefix = []string{
"GET /api/healthcheck",
"HEAD /api/healthcheck",
"GET /favicon.ico",
}
)
func logPath(path string) bool {
for _, prefix := range loggerSkipPathsPrefix {
if strings.HasPrefix(path, prefix) {
return false
}
}
return true
}
func NewServer(config types.ServerConfig, handlers *handlers.Handlers) (*Server, error) {
gin.SetMode(gin.ReleaseMode)
log.Debug().Msg("Setting up router")
router := gin.New()
router.Use(zerolog())
log.Debug().Msg("Setting up assets")
dist, err := fs.Sub(assets.Assets, "dist")
if err != nil {
return nil, err
}
log.Debug().Msg("Setting up file server")
fileServer := http.FileServer(http.FS(dist))
// UI middleware
router.Use(func(c *gin.Context) {
// If not an API request, serve the UI
if !strings.HasPrefix(c.Request.URL.Path, "/api") {
_, err := fs.Stat(dist, strings.TrimPrefix(c.Request.URL.Path, "/"))
if os.IsNotExist(err) {
c.Request.URL.Path = "/"
}
fileServer.ServeHTTP(c.Writer, c.Request)
c.Abort()
}
})
// Proxy routes
router.GET("/api/auth/:proxy", handlers.ProxyHandler)
// Auth routes
router.POST("/api/login", handlers.LoginHandler)
router.POST("/api/totp", handlers.TOTPHandler)
router.POST("/api/logout", handlers.LogoutHandler)
// Context routes
router.GET("/api/app", handlers.AppContextHandler)
router.GET("/api/user", handlers.UserContextHandler)
// OAuth routes
router.GET("/api/oauth/url/:provider", handlers.OAuthURLHandler)
router.GET("/api/oauth/callback/:provider", handlers.OAuthCallbackHandler)
// App routes
router.GET("/api/healthcheck", handlers.HealthcheckHandler)
router.HEAD("/api/healthcheck", handlers.HealthcheckHandler)
return &Server{
Config: config,
Handlers: handlers,
Router: router,
}, nil
}
func (s *Server) Start() error {
log.Info().Str("address", s.Config.Address).Int("port", s.Config.Port).Msg("Starting server")
return s.Router.Run(fmt.Sprintf("%s:%d", s.Config.Address, s.Config.Port))
}
// zerolog is a middleware for gin that logs requests using zerolog
func zerolog() gin.HandlerFunc {
return func(c *gin.Context) {
tStart := time.Now()
c.Next()
code := c.Writer.Status()
address := c.Request.RemoteAddr
method := c.Request.Method
path := c.Request.URL.Path
latency := time.Since(tStart).String()
// logPath check if the path should be logged normally or with debug
if logPath(method + " " + path) {
switch {
case code >= 200 && code < 300:
log.Info().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 300 && code < 400:
log.Warn().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 400:
log.Error().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
}
} else {
log.Debug().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
}
}
}

View File

@@ -34,8 +34,14 @@ type Config struct {
EnvFile string `mapstructure:"env-file"` EnvFile string `mapstructure:"env-file"`
LoginTimeout int `mapstructure:"login-timeout"` LoginTimeout int `mapstructure:"login-timeout"`
LoginMaxRetries int `mapstructure:"login-max-retries"` LoginMaxRetries int `mapstructure:"login-max-retries"`
FogotPasswordMessage string `mapstructure:"forgot-password-message" validate:"required"` FogotPasswordMessage string `mapstructure:"forgot-password-message"`
BackgroundImage string `mapstructure:"background-image" validate:"required"` BackgroundImage string `mapstructure:"background-image" validate:"required"`
LdapAddress string `mapstructure:"ldap-address"`
LdapBindDN string `mapstructure:"ldap-bind-dn"`
LdapBindPassword string `mapstructure:"ldap-bind-password"`
LdapBaseDN string `mapstructure:"ldap-base-dn"`
LdapInsecure bool `mapstructure:"ldap-insecure"`
LdapSearchFilter string `mapstructure:"ldap-search-filter"`
} }
// Server configuration // Server configuration
@@ -69,8 +75,8 @@ type OAuthConfig struct {
AppURL string AppURL string
} }
// APIConfig is the configuration for the API // ServerConfig is the configuration for the server
type APIConfig struct { type ServerConfig struct {
Port int Port int
Address string Address string
} }
@@ -102,14 +108,21 @@ type OAuthLabels struct {
// Basic auth labels for a tinyauth protected container // Basic auth labels for a tinyauth protected container
type BasicLabels struct { type BasicLabels struct {
User string Username string
Password string Password PassowrdLabels
}
// PassowrdLabels is a struct that contains the password labels for a tinyauth protected container
type PassowrdLabels struct {
Plain string
File string
} }
// IP labels for a tinyauth protected container // IP labels for a tinyauth protected container
type IPLabels struct { type IPLabels struct {
Allow []string Allow []string
Block []string Block []string
Bypass []string
} }
// Labels is a struct that contains the labels for a tinyauth protected container // Labels is a struct that contains the labels for a tinyauth protected container
@@ -117,8 +130,18 @@ type Labels struct {
Users string Users string
Allowed string Allowed string
Headers []string Headers []string
Domain string Domain []string
Basic BasicLabels Basic BasicLabels
OAuth OAuthLabels OAuth OAuthLabels
IP IPLabels IP IPLabels
} }
// Ldap config is a struct that contains the configuration for the LDAP service
type LdapConfig struct {
Address string
BindDN string
BindPassword string
BaseDN string
Insecure bool
SearchFilter string
}

View File

@@ -12,6 +12,12 @@ type User struct {
TotpSecret string TotpSecret string
} }
// UserSearch is the response of the get user
type UserSearch struct {
Username string
Type string // "local", "ldap" or empty
}
// Users is a list of users // Users is a list of users
type Users []User type Users []User

View File

@@ -24,171 +24,121 @@ import (
func ParseUsers(users string) (types.Users, error) { func ParseUsers(users string) (types.Users, error) {
log.Debug().Msg("Parsing users") log.Debug().Msg("Parsing users")
// Create a new users struct
var usersParsed types.Users var usersParsed types.Users
// Split the users by comma
userList := strings.Split(users, ",") userList := strings.Split(users, ",")
// Check if there are any users
if len(userList) == 0 { if len(userList) == 0 {
return types.Users{}, errors.New("invalid user format") return types.Users{}, errors.New("invalid user format")
} }
// Loop through the users and split them by colon
for _, user := range userList { for _, user := range userList {
parsed, err := ParseUser(user) parsed, err := ParseUser(user)
// Check if there was an error
if err != nil { if err != nil {
return types.Users{}, err return types.Users{}, err
} }
// Append the user to the users struct
usersParsed = append(usersParsed, parsed) usersParsed = append(usersParsed, parsed)
} }
log.Debug().Msg("Parsed users") log.Debug().Msg("Parsed users")
// Return the users struct
return usersParsed, nil return usersParsed, nil
} }
// Get upper domain parses a hostname and returns the upper domain (e.g. sub1.sub2.domain.com -> sub2.domain.com) // Get upper domain parses a hostname and returns the upper domain (e.g. sub1.sub2.domain.com -> sub2.domain.com)
func GetUpperDomain(urlSrc string) (string, error) { func GetUpperDomain(urlSrc string) (string, error) {
// Make sure the url is valid
urlParsed, err := url.Parse(urlSrc) urlParsed, err := url.Parse(urlSrc)
// Check if there was an error
if err != nil { if err != nil {
return "", err return "", err
} }
// Split the hostname by period
urlSplitted := strings.Split(urlParsed.Hostname(), ".") urlSplitted := strings.Split(urlParsed.Hostname(), ".")
// Get the last part of the url
urlFinal := strings.Join(urlSplitted[1:], ".") urlFinal := strings.Join(urlSplitted[1:], ".")
// Return the root domain
return urlFinal, nil return urlFinal, nil
} }
// Reads a file and returns the contents // Reads a file and returns the contents
func ReadFile(file string) (string, error) { func ReadFile(file string) (string, error) {
// Check if the file exists
_, err := os.Stat(file) _, err := os.Stat(file)
// Check if there was an error
if err != nil { if err != nil {
return "", err return "", err
} }
// Read the file
data, err := os.ReadFile(file) data, err := os.ReadFile(file)
// Check if there was an error
if err != nil { if err != nil {
return "", err return "", err
} }
// Return the file contents
return string(data), nil return string(data), nil
} }
// Parses a file into a comma separated list of users // Parses a file into a comma separated list of users
func ParseFileToLine(content string) string { func ParseFileToLine(content string) string {
// Split the content by newline
lines := strings.Split(content, "\n") lines := strings.Split(content, "\n")
// Create a list of users
users := make([]string, 0) users := make([]string, 0)
// Loop through the lines, trimming the whitespace and appending to the users list
for _, line := range lines { for _, line := range lines {
if strings.TrimSpace(line) == "" { if strings.TrimSpace(line) == "" {
continue continue
} }
users = append(users, strings.TrimSpace(line)) users = append(users, strings.TrimSpace(line))
} }
// Return the users as a comma separated string
return strings.Join(users, ",") return strings.Join(users, ",")
} }
// Get the secret from the config or file // Get the secret from the config or file
func GetSecret(conf string, file string) string { func GetSecret(conf string, file string) string {
// If neither the config or file is set, return an empty string
if conf == "" && file == "" { if conf == "" && file == "" {
return "" return ""
} }
// If the config is set, return the config (environment variable)
if conf != "" { if conf != "" {
return conf return conf
} }
// If the file is set, read the file
contents, err := ReadFile(file) contents, err := ReadFile(file)
// Check if there was an error
if err != nil { if err != nil {
return "" return ""
} }
// Return the contents of the file
return ParseSecretFile(contents) return ParseSecretFile(contents)
} }
// Get the users from the config or file // Get the users from the config or file
func GetUsers(conf string, file string) (types.Users, error) { func GetUsers(conf string, file string) (types.Users, error) {
// Create a string to store the users
var users string var users string
// If neither the config or file is set, return an empty users struct
if conf == "" && file == "" { if conf == "" && file == "" {
return types.Users{}, nil return types.Users{}, nil
} }
// If the config (environment) is set, append the users to the users string
if conf != "" { if conf != "" {
log.Debug().Msg("Using users from config") log.Debug().Msg("Using users from config")
users += conf users += conf
} }
// If the file is set, read the file and append the users to the users string
if file != "" { if file != "" {
// Read the file
contents, err := ReadFile(file) contents, err := ReadFile(file)
// If there isn't an error we can append the users to the users string
if err == nil { if err == nil {
log.Debug().Msg("Using users from file") log.Debug().Msg("Using users from file")
// Append the users to the users string
if users != "" { if users != "" {
users += "," users += ","
} }
// Parse the file contents into a comma separated list of users
users += ParseFileToLine(contents) users += ParseFileToLine(contents)
} }
} }
// Return the parsed users
return ParseUsers(users) return ParseUsers(users)
} }
// Parse the headers in a map[string]string format // Parse the headers in a map[string]string format
func ParseHeaders(headers []string) map[string]string { func ParseHeaders(headers []string) map[string]string {
// Create a map to store the headers
headerMap := make(map[string]string) headerMap := make(map[string]string)
// Loop through the headers
for _, header := range headers { for _, header := range headers {
split := strings.SplitN(header, "=", 2) split := strings.SplitN(header, "=", 2)
if len(split) != 2 { if len(split) != 2 || strings.TrimSpace(split[0]) == "" || strings.TrimSpace(split[1]) == "" {
log.Warn().Str("header", header).Msg("Invalid header format, skipping") log.Warn().Str("header", header).Msg("Invalid header format, skipping")
continue continue
} }
@@ -197,25 +147,19 @@ func ParseHeaders(headers []string) map[string]string {
headerMap[key] = value headerMap[key] = value
} }
// Return the header map
return headerMap return headerMap
} }
// Get labels parses a map of labels into a struct with only the needed labels // Get labels parses a map of labels into a struct with only the needed labels
func GetLabels(labels map[string]string) (types.Labels, error) { func GetLabels(labels map[string]string) (types.Labels, error) {
// Create a new labels struct
var labelsParsed types.Labels var labelsParsed types.Labels
// Decode the labels into the labels struct
err := parser.Decode(labels, &labelsParsed, "tinyauth", "tinyauth.users", "tinyauth.allowed", "tinyauth.headers", "tinyauth.domain", "tinyauth.basic", "tinyauth.oauth", "tinyauth.ip") err := parser.Decode(labels, &labelsParsed, "tinyauth", "tinyauth.users", "tinyauth.allowed", "tinyauth.headers", "tinyauth.domain", "tinyauth.basic", "tinyauth.oauth", "tinyauth.ip")
// Check if there was an error
if err != nil { if err != nil {
log.Error().Err(err).Msg("Error parsing labels") log.Error().Err(err).Msg("Error parsing labels")
return types.Labels{}, err return types.Labels{}, err
} }
// Return the labels struct
return labelsParsed, nil return labelsParsed, nil
} }
@@ -236,27 +180,22 @@ func Filter[T any](slice []T, test func(T) bool) (res []T) {
// Parse user // Parse user
func ParseUser(user string) (types.User, error) { func ParseUser(user string) (types.User, error) {
// Check if the user is escaped
if strings.Contains(user, "$$") { if strings.Contains(user, "$$") {
user = strings.ReplaceAll(user, "$$", "$") user = strings.ReplaceAll(user, "$$", "$")
} }
// Split the user by colon
userSplit := strings.Split(user, ":") userSplit := strings.Split(user, ":")
// Check if the user is in the correct format
if len(userSplit) < 2 || len(userSplit) > 3 { if len(userSplit) < 2 || len(userSplit) > 3 {
return types.User{}, errors.New("invalid user format") return types.User{}, errors.New("invalid user format")
} }
// Check for empty strings
for _, userPart := range userSplit { for _, userPart := range userSplit {
if strings.TrimSpace(userPart) == "" { if strings.TrimSpace(userPart) == "" {
return types.User{}, errors.New("invalid user format") return types.User{}, errors.New("invalid user format")
} }
} }
// Check if the user has a totp secret
if len(userSplit) == 2 { if len(userSplit) == 2 {
return types.User{ return types.User{
Username: strings.TrimSpace(userSplit[0]), Username: strings.TrimSpace(userSplit[0]),
@@ -264,7 +203,6 @@ func ParseUser(user string) (types.User, error) {
}, nil }, nil
} }
// Return the user struct
return types.User{ return types.User{
Username: strings.TrimSpace(userSplit[0]), Username: strings.TrimSpace(userSplit[0]),
Password: strings.TrimSpace(userSplit[1]), Password: strings.TrimSpace(userSplit[1]),
@@ -274,60 +212,44 @@ func ParseUser(user string) (types.User, error) {
// Parse secret file // Parse secret file
func ParseSecretFile(contents string) string { func ParseSecretFile(contents string) string {
// Split to lines
lines := strings.Split(contents, "\n") lines := strings.Split(contents, "\n")
// Loop through the lines
for _, line := range lines { for _, line := range lines {
// Check if the line is empty
if strings.TrimSpace(line) == "" { if strings.TrimSpace(line) == "" {
continue continue
} }
// Return the line
return strings.TrimSpace(line) return strings.TrimSpace(line)
} }
// Return an empty string
return "" return ""
} }
// Check if a string matches a regex or a whitelist // Check if a string matches a regex or if it is included in a comma separated list
func CheckWhitelist(whitelist string, str string) bool { func CheckFilter(filter string, str string) bool {
// Check if the whitelist is empty if len(strings.TrimSpace(filter)) == 0 {
if len(strings.TrimSpace(whitelist)) == 0 {
return true return true
} }
// Check if the whitelist is a regex if strings.HasPrefix(filter, "/") && strings.HasSuffix(filter, "/") {
if strings.HasPrefix(whitelist, "/") && strings.HasSuffix(whitelist, "/") { re, err := regexp.Compile(filter[1 : len(filter)-1])
// Create regex
re, err := regexp.Compile(whitelist[1 : len(whitelist)-1])
// Check if there was an error
if err != nil { if err != nil {
log.Error().Err(err).Msg("Error compiling regex") log.Error().Err(err).Msg("Error compiling regex")
return false return false
} }
// Check if the string matches the regex
if re.MatchString(str) { if re.MatchString(str) {
return true return true
} }
} }
// Split the whitelist by comma filterSplit := strings.Split(filter, ",")
whitelistSplit := strings.Split(whitelist, ",")
// Loop through the whitelist for _, item := range filterSplit {
for _, item := range whitelistSplit {
// Check if the item matches with the string
if strings.TrimSpace(item) == str { if strings.TrimSpace(item) == str {
return true return true
} }
} }
// Return false if no match was found
return false return false
} }
@@ -352,89 +274,77 @@ func SanitizeHeader(header string) string {
// Generate a static identifier from a string // Generate a static identifier from a string
func GenerateIdentifier(str string) string { func GenerateIdentifier(str string) string {
// Create a new UUID
uuid := uuid.NewSHA1(uuid.NameSpaceURL, []byte(str)) uuid := uuid.NewSHA1(uuid.NameSpaceURL, []byte(str))
// Convert the UUID to a string
uuidString := uuid.String() uuidString := uuid.String()
// Show the UUID
log.Debug().Str("uuid", uuidString).Msg("Generated UUID") log.Debug().Str("uuid", uuidString).Msg("Generated UUID")
// Convert the UUID to a string
return strings.Split(uuidString, "-")[0] return strings.Split(uuidString, "-")[0]
} }
// Get a basic auth header from a username and password // Get a basic auth header from a username and password
func GetBasicAuth(username string, password string) string { func GetBasicAuth(username string, password string) string {
// Create the auth string
auth := username + ":" + password auth := username + ":" + password
// Encode the auth string to base64
return base64.StdEncoding.EncodeToString([]byte(auth)) return base64.StdEncoding.EncodeToString([]byte(auth))
} }
// Check if an IP is contained in a CIDR range/matches a single IP // Check if an IP is contained in a CIDR range/matches a single IP
func FilterIP(filter string, ip string) (bool, error) { func FilterIP(filter string, ip string) (bool, error) {
// Convert the check IP to an IP instance
ipAddr := net.ParseIP(ip) ipAddr := net.ParseIP(ip)
// Check if the filter is a CIDR range
if strings.Contains(filter, "/") { if strings.Contains(filter, "/") {
// Parse the CIDR range
_, cidr, err := net.ParseCIDR(filter) _, cidr, err := net.ParseCIDR(filter)
// Check if there was an error
if err != nil { if err != nil {
return false, err return false, err
} }
// Check if the IP is in the CIDR range
return cidr.Contains(ipAddr), nil return cidr.Contains(ipAddr), nil
} }
// Parse the filter as a single IP
ipFilter := net.ParseIP(filter) ipFilter := net.ParseIP(filter)
// Check if the IP is valid
if ipFilter == nil { if ipFilter == nil {
return false, errors.New("invalid IP address in filter") return false, errors.New("invalid IP address in filter")
} }
// Check if the IP matches the filter
if ipFilter.Equal(ipAddr) { if ipFilter.Equal(ipAddr) {
return true, nil return true, nil
} }
// If the filter is not a CIDR range or a single IP, return false
return false, nil return false, nil
} }
func DeriveKey(secret string, info string) (string, error) { func DeriveKey(secret string, info string) (string, error) {
// Create hashing function
hash := sha256.New hash := sha256.New
// Create a new key using the secret and info
hkdf := hkdf.New(hash, []byte(secret), nil, []byte(info)) // I am not using a salt because I just want two different keys from one secret, maybe bad practice hkdf := hkdf.New(hash, []byte(secret), nil, []byte(info)) // I am not using a salt because I just want two different keys from one secret, maybe bad practice
// Create a new key
key := make([]byte, 24) key := make([]byte, 24)
// Read the key from the HKDF
_, err := io.ReadFull(hkdf, key) _, err := io.ReadFull(hkdf, key)
if err != nil { if err != nil {
return "", err return "", err
} }
// Verify the key is not empty
if bytes.Equal(key, make([]byte, 24)) { if bytes.Equal(key, make([]byte, 24)) {
return "", errors.New("derived key is empty") return "", errors.New("derived key is empty")
} }
// Encode the key to base64
encodedKey := base64.StdEncoding.EncodeToString(key) encodedKey := base64.StdEncoding.EncodeToString(key)
// Return the key as a base64 encoded string
return encodedKey, nil return encodedKey, nil
} }
func CoalesceToString(value any) string {
switch v := value.(type) {
case []any:
log.Debug().Msg("Coalescing []any to string")
strs := make([]string, 0, len(v))
for _, item := range v {
if str, ok := item.(string); ok {
strs = append(strs, str)
continue
}
log.Warn().Interface("item", item).Msg("Item in []any is not a string, skipping")
}
return strings.Join(strs, ",")
case string:
return v
default:
log.Warn().Interface("value", value).Interface("type", v).Msg("Unsupported type, returning empty string")
return ""
}
}

View File

@@ -9,11 +9,9 @@ import (
"tinyauth/internal/utils" "tinyauth/internal/utils"
) )
// Test the parse users function
func TestParseUsers(t *testing.T) { func TestParseUsers(t *testing.T) {
t.Log("Testing parse users with a valid string") t.Log("Testing parse users with a valid string")
// Test the parse users function with a valid string
users := "user1:pass1,user2:pass2" users := "user1:pass1,user2:pass2"
expected := types.Users{ expected := types.Users{
{ {
@@ -27,154 +25,116 @@ func TestParseUsers(t *testing.T) {
} }
result, err := utils.ParseUsers(users) result, err := utils.ParseUsers(users)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error parsing users: %v", err) t.Fatalf("Error parsing users: %v", err)
} }
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) { if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
} }
// Test the get upper domain function
func TestGetUpperDomain(t *testing.T) { func TestGetUpperDomain(t *testing.T) {
t.Log("Testing get upper domain with a valid url") t.Log("Testing get upper domain with a valid url")
// Test the get upper domain function with a valid url
url := "https://sub1.sub2.domain.com:8080" url := "https://sub1.sub2.domain.com:8080"
expected := "sub2.domain.com" expected := "sub2.domain.com"
result, err := utils.GetUpperDomain(url) result, err := utils.GetUpperDomain(url)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error getting root url: %v", err) t.Fatalf("Error getting root url: %v", err)
} }
// Check if the result is equal to the expected
if expected != result { if expected != result {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
} }
// Test the read file function
func TestReadFile(t *testing.T) { func TestReadFile(t *testing.T) {
t.Log("Creating a test file") t.Log("Creating a test file")
// Create a test file
err := os.WriteFile("/tmp/test.txt", []byte("test"), 0644) err := os.WriteFile("/tmp/test.txt", []byte("test"), 0644)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error creating test file: %v", err) t.Fatalf("Error creating test file: %v", err)
} }
// Test the read file function
t.Log("Testing read file with a valid file") t.Log("Testing read file with a valid file")
data, err := utils.ReadFile("/tmp/test.txt") data, err := utils.ReadFile("/tmp/test.txt")
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error reading file: %v", err) t.Fatalf("Error reading file: %v", err)
} }
// Check if the data is equal to the expected
if data != "test" { if data != "test" {
t.Fatalf("Expected test, got %v", data) t.Fatalf("Expected test, got %v", data)
} }
// Cleanup the test file
t.Log("Cleaning up test file") t.Log("Cleaning up test file")
err = os.Remove("/tmp/test.txt") err = os.Remove("/tmp/test.txt")
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error cleaning up test file: %v", err) t.Fatalf("Error cleaning up test file: %v", err)
} }
} }
// Test the parse file to line function
func TestParseFileToLine(t *testing.T) { func TestParseFileToLine(t *testing.T) {
t.Log("Testing parse file to line with a valid string") t.Log("Testing parse file to line with a valid string")
// Test the parse file to line function with a valid string
content := "\nuser1:pass1\nuser2:pass2\n" content := "\nuser1:pass1\nuser2:pass2\n"
expected := "user1:pass1,user2:pass2" expected := "user1:pass1,user2:pass2"
result := utils.ParseFileToLine(content) result := utils.ParseFileToLine(content)
// Check if the result is equal to the expected
if expected != result { if expected != result {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
} }
// Test the get secret function
func TestGetSecret(t *testing.T) { func TestGetSecret(t *testing.T) {
t.Log("Testing get secret with an empty config and file") t.Log("Testing get secret with an empty config and file")
// Test the get secret function with an empty config and file
conf := "" conf := ""
file := "/tmp/test.txt" file := "/tmp/test.txt"
expected := "test" expected := "test"
// Create file
err := os.WriteFile(file, []byte(fmt.Sprintf("\n\n \n\n\n %s \n\n \n ", expected)), 0644) err := os.WriteFile(file, []byte(fmt.Sprintf("\n\n \n\n\n %s \n\n \n ", expected)), 0644)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error creating test file: %v", err) t.Fatalf("Error creating test file: %v", err)
} }
// Test
result := utils.GetSecret(conf, file) result := utils.GetSecret(conf, file)
// Check if the result is equal to the expected
if result != expected { if result != expected {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
t.Log("Testing get secret with an empty file and a valid config") t.Log("Testing get secret with an empty file and a valid config")
// Test the get secret function with an empty file and a valid config
result = utils.GetSecret(expected, "") result = utils.GetSecret(expected, "")
// Check if the result is equal to the expected
if result != expected { if result != expected {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
t.Log("Testing get secret with both a valid config and file") t.Log("Testing get secret with both a valid config and file")
// Test the get secret function with both a valid config and file
result = utils.GetSecret(expected, file) result = utils.GetSecret(expected, file)
// Check if the result is equal to the expected
if result != expected { if result != expected {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
// Cleanup the test file
t.Log("Cleaning up test file") t.Log("Cleaning up test file")
err = os.Remove(file) err = os.Remove(file)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error cleaning up test file: %v", err) t.Fatalf("Error cleaning up test file: %v", err)
} }
} }
// Test the get users function
func TestGetUsers(t *testing.T) { func TestGetUsers(t *testing.T) {
t.Log("Testing get users with a config and no file") t.Log("Testing get users with a config and no file")
// Test the get users function with a config and no file
conf := "user1:pass1,user2:pass2" conf := "user1:pass1,user2:pass2"
file := "" file := ""
expected := types.Users{ expected := types.Users{
@@ -189,20 +149,16 @@ func TestGetUsers(t *testing.T) {
} }
result, err := utils.GetUsers(conf, file) result, err := utils.GetUsers(conf, file)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error getting users: %v", err) t.Fatalf("Error getting users: %v", err)
} }
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) { if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
t.Log("Testing get users with a file and no config") t.Log("Testing get users with a file and no config")
// Test the get users function with a file and no config
conf = "" conf = ""
file = "/tmp/test.txt" file = "/tmp/test.txt"
expected = types.Users{ expected = types.Users{
@@ -216,28 +172,20 @@ func TestGetUsers(t *testing.T) {
}, },
} }
// Create file
err = os.WriteFile(file, []byte("user1:pass1\nuser2:pass2"), 0644) err = os.WriteFile(file, []byte("user1:pass1\nuser2:pass2"), 0644)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error creating test file: %v", err) t.Fatalf("Error creating test file: %v", err)
} }
// Test
result, err = utils.GetUsers(conf, file) result, err = utils.GetUsers(conf, file)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error getting users: %v", err) t.Fatalf("Error getting users: %v", err)
} }
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) { if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
// Test the get users function with both a config and file
t.Log("Testing get users with both a config and file") t.Log("Testing get users with both a config and file")
conf = "user3:pass3" conf = "user3:pass3"
@@ -257,33 +205,25 @@ func TestGetUsers(t *testing.T) {
} }
result, err = utils.GetUsers(conf, file) result, err = utils.GetUsers(conf, file)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error getting users: %v", err) t.Fatalf("Error getting users: %v", err)
} }
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) { if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
// Cleanup the test file
t.Log("Cleaning up test file") t.Log("Cleaning up test file")
err = os.Remove(file) err = os.Remove(file)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error cleaning up test file: %v", err) t.Fatalf("Error cleaning up test file: %v", err)
} }
} }
// Test the get labels function
func TestGetLabels(t *testing.T) { func TestGetLabels(t *testing.T) {
t.Log("Testing get labels with a valid map") t.Log("Testing get labels with a valid map")
// Test the get tinyauth labels function with a valid map
labels := map[string]string{ labels := map[string]string{
"tinyauth.users": "user1,user2", "tinyauth.users": "user1,user2",
"tinyauth.oauth.whitelist": "/regex/", "tinyauth.oauth.whitelist": "/regex/",
@@ -303,42 +243,18 @@ func TestGetLabels(t *testing.T) {
} }
result, err := utils.GetLabels(labels) result, err := utils.GetLabels(labels)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error getting labels: %v", err) t.Fatalf("Error getting labels: %v", err)
} }
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) { if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
} }
// Test the filter function
func TestFilter(t *testing.T) {
t.Log("Testing filter helper")
// Create variables
data := []string{"", "val1", "", "val2", "", "val3", ""}
expected := []string{"val1", "val2", "val3"}
// Test the filter function
result := utils.Filter(data, func(val string) bool {
return val != ""
})
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
// Test parse user
func TestParseUser(t *testing.T) { func TestParseUser(t *testing.T) {
t.Log("Testing parse user with a valid user") t.Log("Testing parse user with a valid user")
// Create variables
user := "user:pass:secret" user := "user:pass:secret"
expected := types.User{ expected := types.User{
Username: "user", Username: "user",
@@ -346,22 +262,17 @@ func TestParseUser(t *testing.T) {
TotpSecret: "secret", TotpSecret: "secret",
} }
// Test the parse user function
result, err := utils.ParseUser(user) result, err := utils.ParseUser(user)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error parsing user: %v", err) t.Fatalf("Error parsing user: %v", err)
} }
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) { if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
t.Log("Testing parse user with an escaped user") t.Log("Testing parse user with an escaped user")
// Create variables
user = "user:p$$ass$$:secret" user = "user:p$$ass$$:secret"
expected = types.User{ expected = types.User{
Username: "user", Username: "user",
@@ -369,168 +280,268 @@ func TestParseUser(t *testing.T) {
TotpSecret: "secret", TotpSecret: "secret",
} }
// Test the parse user function
result, err = utils.ParseUser(user) result, err = utils.ParseUser(user)
// Check if there was an error
if err != nil { if err != nil {
t.Fatalf("Error parsing user: %v", err) t.Fatalf("Error parsing user: %v", err)
} }
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) { if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
t.Log("Testing parse user with an invalid user") t.Log("Testing parse user with an invalid user")
// Create variables
user = "user::pass" user = "user::pass"
// Test the parse user function
_, err = utils.ParseUser(user) _, err = utils.ParseUser(user)
// Check if there was an error
if err == nil { if err == nil {
t.Fatalf("Expected error parsing user") t.Fatalf("Expected error parsing user")
} }
} }
// Test the whitelist function func TestCheckFilter(t *testing.T) {
func TestCheckWhitelist(t *testing.T) { t.Log("Testing check filter with a comma separated list")
t.Log("Testing check whitelist with a comma whitelist")
// Create variables filter := "user1,user2,user3"
whitelist := "user1,user2,user3"
str := "user1" str := "user1"
expected := true expected := true
// Test the check whitelist function result := utils.CheckFilter(filter, str)
result := utils.CheckWhitelist(whitelist, str)
// Check if the result is equal to the expected
if result != expected { if result != expected {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
t.Log("Testing check whitelist with a regex whitelist") t.Log("Testing check filter with a regex filter")
// Create variables filter = "/^user[0-9]+$/"
whitelist = "/^user[0-9]+$/"
str = "user1" str = "user1"
expected = true expected = true
// Test the check whitelist function result = utils.CheckFilter(filter, str)
result = utils.CheckWhitelist(whitelist, str)
// Check if the result is equal to the expected
if result != expected { if result != expected {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
t.Log("Testing check whitelist with an empty whitelist") t.Log("Testing check filter with an empty filter")
// Create variables filter = ""
whitelist = ""
str = "user1" str = "user1"
expected = true expected = true
// Test the check whitelist function result = utils.CheckFilter(filter, str)
result = utils.CheckWhitelist(whitelist, str)
// Check if the result is equal to the expected
if result != expected { if result != expected {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
t.Log("Testing check whitelist with an invalid regex whitelist") t.Log("Testing check filter with an invalid regex filter")
// Create variables filter = "/^user[0-9+$/"
whitelist = "/^user[0-9+$/"
str = "user1" str = "user1"
expected = false expected = false
// Test the check whitelist function result = utils.CheckFilter(filter, str)
result = utils.CheckWhitelist(whitelist, str)
// Check if the result is equal to the expected
if result != expected { if result != expected {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
t.Log("Testing check whitelist with a non matching whitelist") t.Log("Testing check filter with a non matching list")
// Create variables filter = "user1,user2,user3"
whitelist = "user1,user2,user3"
str = "user4" str = "user4"
expected = false expected = false
// Test the check whitelist function result = utils.CheckFilter(filter, str)
result = utils.CheckWhitelist(whitelist, str)
// Check if the result is equal to the expected
if result != expected { if result != expected {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
} }
// Test capitalize
func TestCapitalize(t *testing.T) {
t.Log("Testing capitalize with a valid string")
// Create variables
str := "test"
expected := "Test"
// Test the capitalize function
result := utils.Capitalize(str)
// Check if the result is equal to the expected
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing capitalize with an empty string")
// Create variables
str = ""
expected = ""
// Test the capitalize function
result = utils.Capitalize(str)
// Check if the result is equal to the expected
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
// Test the header sanitizer
func TestSanitizeHeader(t *testing.T) { func TestSanitizeHeader(t *testing.T) {
t.Log("Testing sanitize header with a valid string") t.Log("Testing sanitize header with a valid string")
// Create variables
str := "X-Header=value" str := "X-Header=value"
expected := "X-Header=value" expected := "X-Header=value"
// Test the sanitize header function
result := utils.SanitizeHeader(str) result := utils.SanitizeHeader(str)
// Check if the result is equal to the expected
if result != expected { if result != expected {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }
t.Log("Testing sanitize header with an invalid string") t.Log("Testing sanitize header with an invalid string")
// Create variables
str = "X-Header=val\nue" str = "X-Header=val\nue"
expected = "X-Header=value" expected = "X-Header=value"
// Test the sanitize header function
result = utils.SanitizeHeader(str) result = utils.SanitizeHeader(str)
// Check if the result is equal to the expected if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
func TestParseHeaders(t *testing.T) {
t.Log("Testing parse headers with a valid string")
headers := []string{"X-Hea\x00der1=value1", "X-Header2=value\n2"}
expected := map[string]string{
"X-Header1": "value1",
"X-Header2": "value2",
}
result := utils.ParseHeaders(headers)
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing parse headers with an invalid string")
headers = []string{"X-Header1=", "X-Header2", "=value", "X-Header3=value3"}
expected = map[string]string{"X-Header3": "value3"}
result = utils.ParseHeaders(headers)
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
func TestParseSecretFile(t *testing.T) {
t.Log("Testing parse secret file with a valid file")
content := "\n\n \n\n\n secret \n\n \n "
expected := "secret"
result := utils.ParseSecretFile(content)
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
func TestFilterIP(t *testing.T) {
t.Log("Testing filter IP with an IP and a valid CIDR")
ip := "10.10.10.10"
filter := "10.10.10.0/24"
expected := true
result, err := utils.FilterIP(filter, ip)
if err != nil {
t.Fatalf("Error filtering IP: %v", err)
}
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing filter IP with an IP and a valid IP")
filter = "10.10.10.10"
expected = true
result, err = utils.FilterIP(filter, ip)
if err != nil {
t.Fatalf("Error filtering IP: %v", err)
}
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing filter IP with an IP and an non matching CIDR")
filter = "10.10.15.0/24"
expected = false
result, err = utils.FilterIP(filter, ip)
if err != nil {
t.Fatalf("Error filtering IP: %v", err)
}
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing filter IP with a non matching IP and a valid CIDR")
filter = "10.10.10.11"
expected = false
result, err = utils.FilterIP(filter, ip)
if err != nil {
t.Fatalf("Error filtering IP: %v", err)
}
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing filter IP with an IP and an invalid CIDR")
filter = "10.../83"
_, err = utils.FilterIP(filter, ip)
if err == nil {
t.Fatalf("Expected error filtering IP")
}
}
func TestDeriveKey(t *testing.T) {
t.Log("Testing the derive key function")
master := "master"
info := "info"
expected := "gdrdU/fXzclYjiSXRexEatVgV13qQmKl"
result, err := utils.DeriveKey(master, info)
if err != nil {
t.Fatalf("Error deriving key: %v", err)
}
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
func TestCoalesceToString(t *testing.T) {
t.Log("Testing coalesce to string with a string")
value := any("test")
expected := "test"
result := utils.CoalesceToString(value)
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing coalesce to string with a slice of strings")
value = []any{any("test1"), any("test2"), any(123)}
expected = "test1,test2"
result = utils.CoalesceToString(value)
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing coalesce to string with an unsupported type")
value = 12345
expected = ""
result = utils.CoalesceToString(value)
if result != expected { if result != expected {
t.Fatalf("Expected %v, got %v", expected, result) t.Fatalf("Expected %v, got %v", expected, result)
} }

View File

@@ -10,9 +10,6 @@ import (
) )
func main() { func main() {
// Logger
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger().Level(zerolog.FatalLevel) log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger().Level(zerolog.FatalLevel)
// Run cmd
cmd.Execute() cmd.Execute()
} }