mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-12-19 22:52:30 +00:00
Compare commits
3 Commits
refactor/a
...
refactor/e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ad862d86c | ||
|
|
5f7e89c330 | ||
|
|
330c7aa8f1 |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -29,10 +29,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo testing > internal/assets/version
|
echo testing > internal/assets/version
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Lint frontend
|
||||||
run: |
|
run: |
|
||||||
cd frontend
|
cd frontend
|
||||||
bun run build
|
bun run lint
|
||||||
|
|
||||||
- name: Copy frontend
|
- name: Copy frontend
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"name": "tinyauth-shadcn",
|
"name": "tinyauth-shadcn",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
@@ -213,6 +214,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
|
||||||
|
|
||||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||||
|
|
||||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||||
@@ -221,12 +224,18 @@
|
|||||||
|
|
||||||
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
|
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||||
|
|
||||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||||
|
|
||||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||||
|
|
||||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<title>Tinyauth</title>
|
<title>Tinyauth</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="dark">
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"tsc": "tsc -b"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ export const LanguageSelector = () => {
|
|||||||
setLanguage(option as SupportedLanguage);
|
setLanguage(option as SupportedLanguage);
|
||||||
i18n.changeLanguage(option as SupportedLanguage);
|
i18n.changeLanguage(option as SupportedLanguage);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select onValueChange={handleSelect} value={language}>
|
<Select onValueChange={handleSelect} value={language}>
|
||||||
<SelectTrigger className="absolute top-5 right-5">
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select language" />
|
<SelectValue placeholder="Select language" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { LanguageSelector } from "../language/language";
|
|||||||
import { Outlet } from "react-router";
|
import { Outlet } from "react-router";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { DomainWarning } from "../domain-warning/domain-warning";
|
import { DomainWarning } from "../domain-warning/domain-warning";
|
||||||
|
import { ThemeToggle } from "../theme-toggle/theme-toggle";
|
||||||
|
|
||||||
const BaseLayout = ({ children }: { children: React.ReactNode }) => {
|
const BaseLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { backgroundImage, title } = useAppContext();
|
const { backgroundImage, title } = useAppContext();
|
||||||
@@ -20,7 +21,10 @@ const BaseLayout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
backgroundPosition: "center",
|
backgroundPosition: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LanguageSelector />
|
<div className="absolute top-5 right-5 flex flex-row gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
73
frontend/src/components/providers/theme-provider.tsx
Normal file
73
frontend/src/components/providers/theme-provider.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Theme = "dark" | "light" | "system";
|
||||||
|
|
||||||
|
type ThemeProviderProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
defaultTheme?: Theme;
|
||||||
|
storageKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ThemeProviderState = {
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: ThemeProviderState = {
|
||||||
|
theme: "system",
|
||||||
|
setTheme: () => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
defaultTheme = "system",
|
||||||
|
storageKey = "vite-ui-theme",
|
||||||
|
...props
|
||||||
|
}: ThemeProviderProps) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(
|
||||||
|
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
|
root.classList.remove("light", "dark");
|
||||||
|
|
||||||
|
if (theme === "system") {
|
||||||
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
.matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
|
||||||
|
root.classList.add(systemTheme);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.classList.add(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
theme,
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
localStorage.setItem(storageKey, theme);
|
||||||
|
setTheme(theme);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProviderContext.Provider {...props} value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeProviderContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = useContext(ThemeProviderContext);
|
||||||
|
|
||||||
|
if (context === undefined)
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
40
frontend/src/components/theme-toggle/theme-toggle.tsx
Normal file
40
frontend/src/components/theme-toggle/theme-toggle.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useTheme } from "@/components/providers/theme-provider";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="bg-card text-card-foreground hover:bg-card/90"
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
|
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||||
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||||
|
Light
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||||
|
Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||||
|
System
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:cursor-pointer",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
255
frontend/src/components/ui/dropdown-menu.tsx
Normal file
255
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ function SelectTrigger({
|
|||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-card dark:hover:bg-card/90 flex w-fit items-center justify-between gap-2 rounded-md border bg-card hover:bg-card/90 px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"hover:cursor-pointer border-input data-[placeholder]:text-card-foreground [&_svg:not([class*='text-'])]:text-card-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-md border bg-card hover:bg-card/90 px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "../providers/theme-provider";
|
||||||
import { Toaster as Sonner, ToasterProps } from "sonner";
|
import { Toaster as Sonner, ToasterProps } from "sonner";
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const isValidUrl = (url: string) => {
|
|||||||
try {
|
try {
|
||||||
new URL(url);
|
new URL(url);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { AppContextProvider } from "./context/app-context.tsx";
|
import { AppContextProvider } from "./context/app-context.tsx";
|
||||||
import { UserContextProvider } from "./context/user-context.tsx";
|
import { UserContextProvider } from "./context/user-context.tsx";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { ThemeProvider } from "./components/providers/theme-provider.tsx";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -24,25 +25,27 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AppContextProvider>
|
<AppContextProvider>
|
||||||
<UserContextProvider>
|
<UserContextProvider>
|
||||||
<BrowserRouter>
|
<ThemeProvider defaultTheme="system" storageKey="tinyauth-theme">
|
||||||
<Routes>
|
<BrowserRouter>
|
||||||
<Route element={<Layout />} errorElement={<ErrorPage />}>
|
<Routes>
|
||||||
<Route path="/" element={<App />} />
|
<Route element={<Layout />} errorElement={<ErrorPage />}>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/" element={<App />} />
|
||||||
<Route path="/logout" element={<LogoutPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/continue" element={<ContinuePage />} />
|
<Route path="/logout" element={<LogoutPage />} />
|
||||||
<Route path="/totp" element={<TotpPage />} />
|
<Route path="/continue" element={<ContinuePage />} />
|
||||||
<Route
|
<Route path="/totp" element={<TotpPage />} />
|
||||||
path="/forgot-password"
|
<Route
|
||||||
element={<ForgotPasswordPage />}
|
path="/forgot-password"
|
||||||
/>
|
element={<ForgotPasswordPage />}
|
||||||
<Route path="/unauthorized" element={<UnauthorizedPage />} />
|
/>
|
||||||
<Route path="/error" element={<ErrorPage />} />
|
<Route path="/unauthorized" element={<UnauthorizedPage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="/error" element={<ErrorPage />} />
|
||||||
</Route>
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Route>
|
||||||
</BrowserRouter>
|
</Routes>
|
||||||
<Toaster />
|
</BrowserRouter>
|
||||||
|
<Toaster />
|
||||||
|
</ThemeProvider>
|
||||||
</UserContextProvider>
|
</UserContextProvider>
|
||||||
</AppContextProvider>
|
</AppContextProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -76,7 +76,14 @@ export const ContinuePage = () => {
|
|||||||
clearTimeout(auto);
|
clearTimeout(auto);
|
||||||
clearTimeout(reveal);
|
clearTimeout(reveal);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [
|
||||||
|
handleRedirect,
|
||||||
|
isAllowedRedirectProto,
|
||||||
|
isHttpsDowngrade,
|
||||||
|
isLoggedIn,
|
||||||
|
isTrustedRedirectUri,
|
||||||
|
isValidRedirectUri,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ export const LoginPage = () => {
|
|||||||
!isLoggedIn &&
|
!isLoggedIn &&
|
||||||
redirectUri
|
redirectUri
|
||||||
) {
|
) {
|
||||||
|
// Not sure of a better way to do this
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setOauthAutoRedirectHandover(true);
|
setOauthAutoRedirectHandover(true);
|
||||||
oauthMutation.mutate(oauthAutoRedirect);
|
oauthMutation.mutate(oauthAutoRedirect);
|
||||||
redirectButtonTimer.current = window.setTimeout(() => {
|
redirectButtonTimer.current = window.setTimeout(() => {
|
||||||
@@ -126,7 +128,15 @@ export const LoginPage = () => {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [
|
||||||
|
isMounted,
|
||||||
|
oauthProviders.length,
|
||||||
|
providers,
|
||||||
|
isLoggedIn,
|
||||||
|
redirectUri,
|
||||||
|
oauthAutoRedirect,
|
||||||
|
oauthMutation,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get access controls
|
||||||
|
acls, err := utils.GetACLS(os.Environ(), os.Args)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Get cookie domain
|
// Get cookie domain
|
||||||
cookieDomain, err := utils.GetCookieDomain(app.config.AppURL)
|
cookieDomain, err := utils.GetCookieDomain(app.config.AppURL)
|
||||||
|
|
||||||
@@ -139,7 +146,7 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
|
|
||||||
// Create services
|
// Create services
|
||||||
dockerService := service.NewDockerService()
|
dockerService := service.NewDockerService()
|
||||||
aclsService := service.NewAccessControlsService(dockerService)
|
aclsService := service.NewAccessControlsService(dockerService, acls)
|
||||||
authService := service.NewAuthService(authConfig, dockerService, ldapService, database)
|
authService := service.NewAuthService(authConfig, dockerService, ldapService, database)
|
||||||
oauthBrokerService := service.NewOAuthBrokerService(oauthProviders)
|
oauthBrokerService := service.NewOAuthBrokerService(oauthProviders)
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En
|
|||||||
assert.NilError(t, dockerService.Init())
|
assert.NilError(t, dockerService.Init())
|
||||||
|
|
||||||
// Access controls
|
// Access controls
|
||||||
accessControlsService := service.NewAccessControlsService(dockerService)
|
accessControlsService := service.NewAccessControlsService(dockerService, config.Apps{})
|
||||||
|
|
||||||
assert.NilError(t, accessControlsService.Init())
|
assert.NilError(t, accessControlsService.Init())
|
||||||
|
|
||||||
|
|||||||
@@ -1,82 +1,34 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"tinyauth/internal/config"
|
"tinyauth/internal/config"
|
||||||
"tinyauth/internal/utils/decoders"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AccessControlsService struct {
|
type AccessControlsService struct {
|
||||||
docker *DockerService
|
docker *DockerService
|
||||||
envACLs config.Apps
|
nonDocker config.Apps
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAccessControlsService(docker *DockerService) *AccessControlsService {
|
func NewAccessControlsService(docker *DockerService, nonDocker config.Apps) *AccessControlsService {
|
||||||
return &AccessControlsService{
|
return &AccessControlsService{
|
||||||
docker: docker,
|
docker: docker,
|
||||||
|
nonDocker: nonDocker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (acls *AccessControlsService) Init() error {
|
func (acls *AccessControlsService) Init() error {
|
||||||
acls.envACLs = config.Apps{}
|
|
||||||
env := os.Environ()
|
|
||||||
appEnvVars := []string{}
|
|
||||||
|
|
||||||
for _, e := range env {
|
|
||||||
if strings.HasPrefix(e, "TINYAUTH_APPS_") {
|
|
||||||
appEnvVars = append(appEnvVars, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := acls.loadEnvACLs(appEnvVars)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (acls *AccessControlsService) loadEnvACLs(appEnvVars []string) error {
|
func (acls *AccessControlsService) lookupNonDockerACLs(appDomain string) *config.App {
|
||||||
if len(appEnvVars) == 0 {
|
if len(acls.nonDocker.Apps) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
envAcls := map[string]string{}
|
for appName, appACLs := range acls.nonDocker.Apps {
|
||||||
|
|
||||||
for _, e := range appEnvVars {
|
|
||||||
parts := strings.SplitN(e, "=", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize key, this should use the same normalization logic as in utils/decoders/decoders.go
|
|
||||||
key := parts[0]
|
|
||||||
key = strings.ToLower(key)
|
|
||||||
key = strings.ReplaceAll(key, "_", ".")
|
|
||||||
value := parts[1]
|
|
||||||
envAcls[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
apps, err := decoders.DecodeLabels(envAcls)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
acls.envACLs = apps
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (acls *AccessControlsService) lookupEnvACLs(appDomain string) *config.App {
|
|
||||||
if len(acls.envACLs.Apps) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for appName, appACLs := range acls.envACLs.Apps {
|
|
||||||
if appACLs.Config.Domain == appDomain {
|
if appACLs.Config.Domain == appDomain {
|
||||||
return &appACLs
|
return &appACLs
|
||||||
}
|
}
|
||||||
@@ -90,11 +42,11 @@ func (acls *AccessControlsService) lookupEnvACLs(appDomain string) *config.App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (acls *AccessControlsService) GetAccessControls(appDomain string) (config.App, error) {
|
func (acls *AccessControlsService) GetAccessControls(appDomain string) (config.App, error) {
|
||||||
// First check environment variables
|
// First check non-docker apps
|
||||||
envACLs := acls.lookupEnvACLs(appDomain)
|
envACLs := acls.lookupNonDockerACLs(appDomain)
|
||||||
|
|
||||||
if envACLs != nil {
|
if envACLs != nil {
|
||||||
log.Debug().Str("domain", appDomain).Msg("Found matching access controls in environment variables")
|
log.Debug().Str("domain", appDomain).Msg("Found matching access controls in environment variables or flags")
|
||||||
return *envACLs, nil
|
return *envACLs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func (docker *DockerService) GetLabels(appDomain string) (config.App, error) {
|
|||||||
return config.App{}, err
|
return config.App{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
labels, err := decoders.DecodeLabels(inspect.Config.Labels)
|
labels, err := decoders.DecodeLabels[config.Apps](inspect.Config.Labels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config.App{}, err
|
return config.App{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,20 +134,11 @@ func GetLogLevel(level string) zerolog.Level {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetOAuthProvidersConfig(env []string, args []string, appUrl string) (map[string]config.OAuthServiceConfig, error) {
|
func GetOAuthProvidersConfig(environ []string, args []string, appUrl string) (map[string]config.OAuthServiceConfig, error) {
|
||||||
providers := make(map[string]config.OAuthServiceConfig)
|
providers := make(map[string]config.OAuthServiceConfig)
|
||||||
|
|
||||||
// Get from environment variables
|
// Get from environment variables
|
||||||
envMap := make(map[string]string)
|
envProviders, err := decoders.DecodeEnv[config.Providers](environ)
|
||||||
|
|
||||||
for _, e := range env {
|
|
||||||
pair := strings.SplitN(e, "=", 2)
|
|
||||||
if len(pair) == 2 {
|
|
||||||
envMap[pair[0]] = pair[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
envProviders, err := decoders.DecodeEnv[config.Providers, config.OAuthServiceConfig](envMap, "providers")
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -155,25 +146,14 @@ func GetOAuthProvidersConfig(env []string, args []string, appUrl string) (map[st
|
|||||||
|
|
||||||
maps.Copy(providers, envProviders.Providers)
|
maps.Copy(providers, envProviders.Providers)
|
||||||
|
|
||||||
// Get from flags
|
// Get from args
|
||||||
flagsMap := make(map[string]string)
|
argProviders, err := decoders.DecodeFlags[config.Providers](args)
|
||||||
|
|
||||||
for _, arg := range args[1:] {
|
|
||||||
if strings.HasPrefix(arg, "--") {
|
|
||||||
pair := strings.SplitN(arg[2:], "=", 2)
|
|
||||||
if len(pair) == 2 {
|
|
||||||
flagsMap[pair[0]] = pair[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flagProviders, err := decoders.DecodeFlags[config.Providers, config.OAuthServiceConfig](flagsMap, "providers")
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
maps.Copy(providers, flagProviders.Providers)
|
maps.Copy(providers, argProviders.Providers)
|
||||||
|
|
||||||
// For every provider get correct secret from file if set
|
// For every provider get correct secret from file if set
|
||||||
for name, provider := range providers {
|
for name, provider := range providers {
|
||||||
@@ -208,3 +188,28 @@ func GetOAuthProvidersConfig(env []string, args []string, appUrl string) (map[st
|
|||||||
// Return combined providers
|
// Return combined providers
|
||||||
return providers, nil
|
return providers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetACLS(environ []string, args []string) (config.Apps, error) {
|
||||||
|
acls := config.Apps{}
|
||||||
|
acls.Apps = make(map[string]config.App)
|
||||||
|
|
||||||
|
// Get from environment variables
|
||||||
|
envACLs, err := decoders.DecodeEnv[config.Apps](environ)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return config.Apps{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
maps.Copy(acls.Apps, envACLs.Apps)
|
||||||
|
|
||||||
|
// Get from args
|
||||||
|
argACLs, err := decoders.DecodeFlags[config.Apps](args)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return config.Apps{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
maps.Copy(acls.Apps, argACLs.Apps)
|
||||||
|
|
||||||
|
return acls, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -238,8 +238,8 @@ func TestIsRedirectSafeMultiLevel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetOAuthProvidersConfig(t *testing.T) {
|
func TestGetOAuthProvidersConfig(t *testing.T) {
|
||||||
env := []string{"PROVIDERS_CLIENT1_CLIENT_ID=client1-id", "PROVIDERS_CLIENT1_CLIENT_SECRET=client1-secret"}
|
env := []string{"PROVIDERS_CLIENT1_CLIENTID=client1-id", "PROVIDERS_CLIENT1_CLIENTSECRET=client1-secret"}
|
||||||
args := []string{"/tinyauth/tinyauth", "--providers-client2-client-id=client2-id", "--providers-client2-client-secret=client2-secret"}
|
args := []string{"/tinyauth/tinyauth", "--providers-client2-clientid=client2-id", "--providers-client2-clientsecret=client2-secret"}
|
||||||
|
|
||||||
expected := map[string]config.OAuthServiceConfig{
|
expected := map[string]config.OAuthServiceConfig{
|
||||||
"client1": {
|
"client1": {
|
||||||
@@ -278,7 +278,7 @@ func TestGetOAuthProvidersConfig(t *testing.T) {
|
|||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
defer os.Remove("/tmp/tinyauth_test_file")
|
defer os.Remove("/tmp/tinyauth_test_file")
|
||||||
|
|
||||||
env = []string{"PROVIDERS_CLIENT1_CLIENT_ID=client1-id", "PROVIDERS_CLIENT1_CLIENT_SECRET_FILE=/tmp/tinyauth_test_file"}
|
env = []string{"PROVIDERS_CLIENT1_CLIENTID=client1-id", "PROVIDERS_CLIENT1_CLIENTSECRETFILE=/tmp/tinyauth_test_file"}
|
||||||
args = []string{"/tinyauth/tinyauth"}
|
args = []string{"/tinyauth/tinyauth"}
|
||||||
expected = map[string]config.OAuthServiceConfig{
|
expected = map[string]config.OAuthServiceConfig{
|
||||||
"client1": {
|
"client1": {
|
||||||
@@ -293,7 +293,7 @@ func TestGetOAuthProvidersConfig(t *testing.T) {
|
|||||||
assert.DeepEqual(t, expected, result)
|
assert.DeepEqual(t, expected, result)
|
||||||
|
|
||||||
// Case with google provider and no redirect URL
|
// Case with google provider and no redirect URL
|
||||||
env = []string{"PROVIDERS_GOOGLE_CLIENT_ID=google-id", "PROVIDERS_GOOGLE_CLIENT_SECRET=google-secret"}
|
env = []string{"PROVIDERS_GOOGLE_CLIENTID=google-id", "PROVIDERS_GOOGLE_CLIENTSECRET=google-secret"}
|
||||||
args = []string{"/tinyauth/tinyauth"}
|
args = []string{"/tinyauth/tinyauth"}
|
||||||
expected = map[string]config.OAuthServiceConfig{
|
expected = map[string]config.OAuthServiceConfig{
|
||||||
"google": {
|
"google": {
|
||||||
@@ -308,3 +308,39 @@ func TestGetOAuthProvidersConfig(t *testing.T) {
|
|||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.DeepEqual(t, expected, result)
|
assert.DeepEqual(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetACLS(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
env := []string{"TINYAUTH_APPS_APP1_CONFIG_DOMAIN=app1.com", "TINYAUTH_APPS_APP2_CONFIG_DOMAIN=app2.com"}
|
||||||
|
args := []string{"--apps-app3-config-domain=app3.com", "--apps-app4-config-domain=app4.com"}
|
||||||
|
|
||||||
|
expected := config.Apps{
|
||||||
|
Apps: map[string]config.App{
|
||||||
|
"app1": {
|
||||||
|
Config: config.AppConfig{
|
||||||
|
Domain: "app1.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"app2": {
|
||||||
|
Config: config.AppConfig{
|
||||||
|
Domain: "app2.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"app3": {
|
||||||
|
Config: config.AppConfig{
|
||||||
|
Domain: "app3.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"app4": {
|
||||||
|
Config: config.AppConfig{
|
||||||
|
Domain: "app4.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test
|
||||||
|
result, err := utils.GetACLS(env, args)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.DeepEqual(t, expected, result)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
package decoders
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/stoewer/go-strcase"
|
|
||||||
)
|
|
||||||
|
|
||||||
func normalizeKeys[T any](input map[string]string, root string, sep string) map[string]string {
|
|
||||||
knownKeys := getKnownKeys[T]()
|
|
||||||
normalized := make(map[string]string)
|
|
||||||
|
|
||||||
for k, v := range input {
|
|
||||||
parts := []string{"tinyauth"}
|
|
||||||
|
|
||||||
key := strings.ToLower(k)
|
|
||||||
key = strings.ReplaceAll(key, sep, "-")
|
|
||||||
|
|
||||||
suffix := ""
|
|
||||||
|
|
||||||
for _, known := range knownKeys {
|
|
||||||
if strings.HasSuffix(key, known) {
|
|
||||||
suffix = known
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if suffix == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
parts = append(parts, root)
|
|
||||||
|
|
||||||
id := strings.TrimPrefix(key, root+"-")
|
|
||||||
id = strings.TrimSuffix(id, "-"+suffix)
|
|
||||||
|
|
||||||
if id == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
parts = append(parts, id)
|
|
||||||
parts = append(parts, suffix)
|
|
||||||
|
|
||||||
final := ""
|
|
||||||
|
|
||||||
for i, part := range parts {
|
|
||||||
if i > 0 {
|
|
||||||
final += "."
|
|
||||||
}
|
|
||||||
final += strcase.LowerCamelCase(part)
|
|
||||||
}
|
|
||||||
|
|
||||||
normalized[final] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
func getKnownKeys[T any]() []string {
|
|
||||||
var keys []string
|
|
||||||
var t T
|
|
||||||
|
|
||||||
v := reflect.ValueOf(t)
|
|
||||||
typeOfT := v.Type()
|
|
||||||
|
|
||||||
for field := range typeOfT.NumField() {
|
|
||||||
if typeOfT.Field(field).Tag.Get("field") != "" {
|
|
||||||
keys = append(keys, typeOfT.Field(field).Tag.Get("field"))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keys = append(keys, strcase.KebabCase(typeOfT.Field(field).Name))
|
|
||||||
}
|
|
||||||
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
package decoders
|
package decoders
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/traefik/paerser/parser"
|
"github.com/traefik/paerser/env"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DecodeEnv[T any, C any](env map[string]string, subName string) (T, error) {
|
func DecodeEnv[T any](environ []string) (T, error) {
|
||||||
var result T
|
var target T
|
||||||
|
|
||||||
normalized := normalizeKeys[C](env, subName, "_")
|
err := env.Decode(environ, "TINYAUTH_", &target)
|
||||||
|
|
||||||
err := parser.Decode(normalized, &result, "tinyauth", "tinyauth."+subName)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return target, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return target, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
|
|
||||||
func TestDecodeEnv(t *testing.T) {
|
func TestDecodeEnv(t *testing.T) {
|
||||||
// Setup
|
// Setup
|
||||||
env := map[string]string{
|
env := []string{
|
||||||
"PROVIDERS_GOOGLE_CLIENT_ID": "google-client-id",
|
"TINYAUTH_PROVIDERS_GOOGLE_CLIENTID=google-client-id",
|
||||||
"PROVIDERS_GOOGLE_CLIENT_SECRET": "google-client-secret",
|
"TINYAUTH_PROVIDERS_GOOGLE_CLIENTSECRET=google-client-secret",
|
||||||
"PROVIDERS_MY_GITHUB_CLIENT_ID": "github-client-id",
|
"TINYAUTH_PROVIDERS_GITHUB_CLIENTID=github-client-id",
|
||||||
"PROVIDERS_MY_GITHUB_CLIENT_SECRET": "github-client-secret",
|
"TINYAUTH_PROVIDERS_GITHUB_CLIENTSECRET=github-client-secret",
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := config.Providers{
|
expected := config.Providers{
|
||||||
@@ -23,7 +23,7 @@ func TestDecodeEnv(t *testing.T) {
|
|||||||
ClientID: "google-client-id",
|
ClientID: "google-client-id",
|
||||||
ClientSecret: "google-client-secret",
|
ClientSecret: "google-client-secret",
|
||||||
},
|
},
|
||||||
"myGithub": {
|
"github": {
|
||||||
ClientID: "github-client-id",
|
ClientID: "github-client-id",
|
||||||
ClientSecret: "github-client-secret",
|
ClientSecret: "github-client-secret",
|
||||||
},
|
},
|
||||||
@@ -31,7 +31,7 @@ func TestDecodeEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute
|
// Execute
|
||||||
result, err := decoders.DecodeEnv[config.Providers, config.OAuthServiceConfig](env, "providers")
|
result, err := decoders.DecodeEnv[config.Providers](env)
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.DeepEqual(t, result, expected)
|
assert.DeepEqual(t, result, expected)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,32 @@ package decoders
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/traefik/paerser/parser"
|
"github.com/traefik/paerser/flag"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DecodeFlags[T any, C any](flags map[string]string, subName string) (T, error) {
|
func DecodeFlags[T any](args []string) (T, error) {
|
||||||
var result T
|
var target T
|
||||||
|
var formatted = []string{}
|
||||||
|
|
||||||
filtered := filterFlags(flags)
|
for _, arg := range args {
|
||||||
normalized := normalizeKeys[C](filtered, subName, "_")
|
argFmt := strings.TrimPrefix(arg, "--")
|
||||||
|
argParts := strings.SplitN(argFmt, "=", 2)
|
||||||
|
|
||||||
err := parser.Decode(normalized, &result, "tinyauth", "tinyauth."+subName)
|
if len(argParts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := argParts[0]
|
||||||
|
value := argParts[1]
|
||||||
|
|
||||||
|
formatted = append(formatted, "--"+strings.ReplaceAll(key, "-", ".")+"="+value)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := flag.Decode(formatted, &target)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return target, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return target, nil
|
||||||
}
|
|
||||||
|
|
||||||
func filterFlags(flags map[string]string) map[string]string {
|
|
||||||
filtered := make(map[string]string)
|
|
||||||
for k, v := range flags {
|
|
||||||
filtered[strings.TrimPrefix(k, "--")] = v
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
|
|
||||||
func TestDecodeFlags(t *testing.T) {
|
func TestDecodeFlags(t *testing.T) {
|
||||||
// Setup
|
// Setup
|
||||||
flags := map[string]string{
|
args := []string{
|
||||||
"--providers-google-client-id": "google-client-id",
|
"--providers-google-clientid=google-client-id",
|
||||||
"--providers-google-client-secret": "google-client-secret",
|
"--providers-google-clientsecret=google-client-secret",
|
||||||
"--providers-my-github-client-id": "github-client-id",
|
"--providers-github-clientid=github-client-id",
|
||||||
"--providers-my-github-client-secret": "github-client-secret",
|
"--providers-github-clientsecret=github-client-secret",
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := config.Providers{
|
expected := config.Providers{
|
||||||
@@ -23,7 +23,7 @@ func TestDecodeFlags(t *testing.T) {
|
|||||||
ClientID: "google-client-id",
|
ClientID: "google-client-id",
|
||||||
ClientSecret: "google-client-secret",
|
ClientSecret: "google-client-secret",
|
||||||
},
|
},
|
||||||
"myGithub": {
|
"github": {
|
||||||
ClientID: "github-client-id",
|
ClientID: "github-client-id",
|
||||||
ClientSecret: "github-client-secret",
|
ClientSecret: "github-client-secret",
|
||||||
},
|
},
|
||||||
@@ -31,7 +31,7 @@ func TestDecodeFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute
|
// Execute
|
||||||
result, err := decoders.DecodeFlags[config.Providers, config.OAuthServiceConfig](flags, "providers")
|
result, err := decoders.DecodeFlags[config.Providers](args)
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.DeepEqual(t, result, expected)
|
assert.DeepEqual(t, result, expected)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
package decoders
|
package decoders
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"tinyauth/internal/config"
|
|
||||||
|
|
||||||
"github.com/traefik/paerser/parser"
|
"github.com/traefik/paerser/parser"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DecodeLabels(labels map[string]string) (config.Apps, error) {
|
func DecodeLabels[T any](labels map[string]string) (T, error) {
|
||||||
var appLabels config.Apps
|
var target T
|
||||||
|
|
||||||
err := parser.Decode(labels, &appLabels, "tinyauth", "tinyauth.apps")
|
err := parser.Decode(labels, &target, "tinyauth")
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config.Apps{}, err
|
return target, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return appLabels, nil
|
return target, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestDecodeLabels(t *testing.T) {
|
func TestDecodeLabels(t *testing.T) {
|
||||||
// Variables
|
// Setup
|
||||||
|
labels := map[string]string{
|
||||||
|
"tinyauth.apps.foo.config.domain": "example.com",
|
||||||
|
"tinyauth.apps.foo.users.allow": "user1,user2",
|
||||||
|
"tinyauth.apps.foo.users.block": "user3",
|
||||||
|
"tinyauth.apps.foo.oauth.whitelist": "somebody@example.com",
|
||||||
|
"tinyauth.apps.foo.oauth.groups": "group3",
|
||||||
|
"tinyauth.apps.foo.ip.allow": "10.71.0.1/24,10.71.0.2",
|
||||||
|
"tinyauth.apps.foo.ip.block": "10.10.10.10,10.0.0.0/24",
|
||||||
|
"tinyauth.apps.foo.ip.bypass": "192.168.1.1",
|
||||||
|
"tinyauth.apps.foo.response.headers": "X-Foo=Bar,X-Baz=Qux",
|
||||||
|
"tinyauth.apps.foo.response.basicauth.username": "admin",
|
||||||
|
"tinyauth.apps.foo.response.basicauth.password": "password",
|
||||||
|
"tinyauth.apps.foo.response.basicauth.passwordfile": "/path/to/passwordfile",
|
||||||
|
"tinyauth.apps.foo.path.allow": "/public",
|
||||||
|
"tinyauth.apps.foo.path.block": "/private",
|
||||||
|
}
|
||||||
|
|
||||||
expected := config.Apps{
|
expected := config.Apps{
|
||||||
Apps: map[string]config.App{
|
Apps: map[string]config.App{
|
||||||
"foo": {
|
"foo": {
|
||||||
@@ -44,25 +61,9 @@ func TestDecodeLabels(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
test := map[string]string{
|
|
||||||
"tinyauth.apps.foo.config.domain": "example.com",
|
|
||||||
"tinyauth.apps.foo.users.allow": "user1,user2",
|
|
||||||
"tinyauth.apps.foo.users.block": "user3",
|
|
||||||
"tinyauth.apps.foo.oauth.whitelist": "somebody@example.com",
|
|
||||||
"tinyauth.apps.foo.oauth.groups": "group3",
|
|
||||||
"tinyauth.apps.foo.ip.allow": "10.71.0.1/24,10.71.0.2",
|
|
||||||
"tinyauth.apps.foo.ip.block": "10.10.10.10,10.0.0.0/24",
|
|
||||||
"tinyauth.apps.foo.ip.bypass": "192.168.1.1",
|
|
||||||
"tinyauth.apps.foo.response.headers": "X-Foo=Bar,X-Baz=Qux",
|
|
||||||
"tinyauth.apps.foo.response.basicauth.username": "admin",
|
|
||||||
"tinyauth.apps.foo.response.basicauth.password": "password",
|
|
||||||
"tinyauth.apps.foo.response.basicauth.passwordfile": "/path/to/passwordfile",
|
|
||||||
"tinyauth.apps.foo.path.allow": "/public",
|
|
||||||
"tinyauth.apps.foo.path.block": "/private",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test
|
// Test
|
||||||
result, err := decoders.DecodeLabels(test)
|
result, err := decoders.DecodeLabels[config.Apps](labels)
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.DeepEqual(t, expected, result)
|
assert.DeepEqual(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user