Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
df82a02f41
|
|||
|
8a3932a013
|
|||
|
d4a2c5853b
|
|||
|
ee9807e8e0
|
|||
|
bf2a1816db
|
22
drizzle/0004_happy_namorita.sql
Normal file
22
drizzle/0004_happy_namorita.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE `passkey_challenges` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`challenge` text NOT NULL,
|
||||
`user_id` text,
|
||||
`expires_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `passkey_challenges_challenge_unique` ON `passkey_challenges` (`challenge`);--> statement-breakpoint
|
||||
CREATE TABLE `passkeys` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`public_key` text NOT NULL,
|
||||
`counter` integer NOT NULL,
|
||||
`device_type` text NOT NULL,
|
||||
`backed_up` integer NOT NULL,
|
||||
`transports` text,
|
||||
`last_used_at` integer,
|
||||
`created_at` integer,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `passkeys_user_id_idx` ON `passkeys` (`user_id`);
|
||||
1315
drizzle/meta/0004_snapshot.json
Normal file
1315
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
||||
"when": 1768842088321,
|
||||
"tag": "0003_amusing_wendigo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1768876902359,
|
||||
"tag": "0004_happy_namorita",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "chronus",
|
||||
"type": "module",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
@@ -18,6 +18,8 @@
|
||||
"@ceereals/vue-pdf": "^0.2.1",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"astro": "^5.16.11",
|
||||
"astro-icon": "^1.1.5",
|
||||
|
||||
240
pnpm-lock.yaml
generated
240
pnpm-lock.yaml
generated
@@ -26,6 +26,12 @@ importers:
|
||||
'@libsql/client':
|
||||
specifier: ^0.17.0
|
||||
version: 0.17.0
|
||||
'@simplewebauthn/browser':
|
||||
specifier: ^13.2.2
|
||||
version: 13.2.2
|
||||
'@simplewebauthn/server':
|
||||
specifier: ^13.2.2
|
||||
version: 13.2.2
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.18
|
||||
version: 4.1.18(vite@6.4.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))
|
||||
@@ -624,6 +630,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@hexagon/base64@1.1.28':
|
||||
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
|
||||
|
||||
'@iconify-json/heroicons@1.2.3':
|
||||
resolution: {integrity: sha512-n+vmCEgTesRsOpp5AB5ILB6srsgsYK+bieoQBNlafvoEhjVXLq8nIGN4B0v/s4DUfa0dOrjwE/cKJgIKdJXOEg==}
|
||||
|
||||
@@ -801,6 +810,9 @@ packages:
|
||||
'@kurkle/color@0.3.4':
|
||||
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
|
||||
|
||||
'@levischuck/tiny-cbor@0.2.11':
|
||||
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
|
||||
|
||||
'@libsql/client@0.17.0':
|
||||
resolution: {integrity: sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg==}
|
||||
|
||||
@@ -864,6 +876,43 @@ packages:
|
||||
'@oslojs/encoding@1.1.0':
|
||||
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
|
||||
|
||||
'@peculiar/asn1-android@2.6.0':
|
||||
resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==}
|
||||
|
||||
'@peculiar/asn1-cms@2.6.0':
|
||||
resolution: {integrity: sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==}
|
||||
|
||||
'@peculiar/asn1-csr@2.6.0':
|
||||
resolution: {integrity: sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==}
|
||||
|
||||
'@peculiar/asn1-ecc@2.6.0':
|
||||
resolution: {integrity: sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==}
|
||||
|
||||
'@peculiar/asn1-pfx@2.6.0':
|
||||
resolution: {integrity: sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==}
|
||||
|
||||
'@peculiar/asn1-pkcs8@2.6.0':
|
||||
resolution: {integrity: sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==}
|
||||
|
||||
'@peculiar/asn1-pkcs9@2.6.0':
|
||||
resolution: {integrity: sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==}
|
||||
|
||||
'@peculiar/asn1-rsa@2.6.0':
|
||||
resolution: {integrity: sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==}
|
||||
|
||||
'@peculiar/asn1-schema@2.6.0':
|
||||
resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==}
|
||||
|
||||
'@peculiar/asn1-x509-attr@2.6.0':
|
||||
resolution: {integrity: sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==}
|
||||
|
||||
'@peculiar/asn1-x509@2.6.0':
|
||||
resolution: {integrity: sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==}
|
||||
|
||||
'@peculiar/x509@1.14.3':
|
||||
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
@@ -1061,6 +1110,13 @@ packages:
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
'@simplewebauthn/browser@13.2.2':
|
||||
resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==}
|
||||
|
||||
'@simplewebauthn/server@13.2.2':
|
||||
resolution: {integrity: sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0':
|
||||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1374,6 +1430,10 @@ packages:
|
||||
array-iterate@2.0.1:
|
||||
resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==}
|
||||
|
||||
asn1js@3.0.7:
|
||||
resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
astro-icon@1.1.5:
|
||||
resolution: {integrity: sha512-CJYS5nWOw9jz4RpGWmzNQY7D0y2ZZacH7atL2K9DeJXJVaz7/5WrxeyIxO8KASk1jCM96Q4LjRx/F3R+InjJrw==}
|
||||
|
||||
@@ -1651,8 +1711,8 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decode-named-character-reference@1.2.0:
|
||||
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
|
||||
decode-named-character-reference@1.3.0:
|
||||
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||
@@ -2613,8 +2673,8 @@ packages:
|
||||
nlcst-to-string@4.0.0:
|
||||
resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
|
||||
|
||||
node-abi@3.86.0:
|
||||
resolution: {integrity: sha512-sn9Et4N3ynsetj3spsZR729DVlGH6iBG4RiDMV7HEp3guyOW6W3S0unGpLDxT50mXortGUMax/ykUNQXdqc/Xg==}
|
||||
node-abi@3.87.0:
|
||||
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
@@ -2815,6 +2875,13 @@ packages:
|
||||
pump@3.0.3:
|
||||
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
|
||||
|
||||
pvutils@1.1.5:
|
||||
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
@@ -2844,6 +2911,9 @@ packages:
|
||||
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
@@ -3088,8 +3158,8 @@ packages:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar@7.5.3:
|
||||
resolution: {integrity: sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==}
|
||||
tar@7.5.4:
|
||||
resolution: {integrity: sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tiny-inflate@1.0.3:
|
||||
@@ -3138,9 +3208,16 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
tslib@1.14.1:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tsyringe@4.10.0:
|
||||
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
|
||||
@@ -4154,6 +4231,8 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@hexagon/base64@1.1.28': {}
|
||||
|
||||
'@iconify-json/heroicons@1.2.3':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
@@ -4168,7 +4247,7 @@ snapshots:
|
||||
local-pkg: 1.1.2
|
||||
pathe: 2.0.3
|
||||
svgo: 3.3.2
|
||||
tar: 7.5.3
|
||||
tar: 7.5.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -4314,6 +4393,8 @@ snapshots:
|
||||
|
||||
'@kurkle/color@0.3.4': {}
|
||||
|
||||
'@levischuck/tiny-cbor@0.2.11': {}
|
||||
|
||||
'@libsql/client@0.17.0':
|
||||
dependencies:
|
||||
'@libsql/core': 0.17.0
|
||||
@@ -4380,6 +4461,102 @@ snapshots:
|
||||
|
||||
'@oslojs/encoding@1.1.0': {}
|
||||
|
||||
'@peculiar/asn1-android@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-cms@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
'@peculiar/asn1-x509-attr': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-csr@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-ecc@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pfx@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.6.0
|
||||
'@peculiar/asn1-pkcs8': 2.6.0
|
||||
'@peculiar/asn1-rsa': 2.6.0
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pkcs8@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pkcs9@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.6.0
|
||||
'@peculiar/asn1-pfx': 2.6.0
|
||||
'@peculiar/asn1-pkcs8': 2.6.0
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
'@peculiar/asn1-x509-attr': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-rsa@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-schema@2.6.0':
|
||||
dependencies:
|
||||
asn1js: 3.0.7
|
||||
pvtsutils: 1.3.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-x509-attr@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-x509@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
pvtsutils: 1.3.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/x509@1.14.3':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.6.0
|
||||
'@peculiar/asn1-csr': 2.6.0
|
||||
'@peculiar/asn1-ecc': 2.6.0
|
||||
'@peculiar/asn1-pkcs9': 2.6.0
|
||||
'@peculiar/asn1-rsa': 2.6.0
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
pvtsutils: 1.3.6
|
||||
reflect-metadata: 0.2.2
|
||||
tslib: 2.8.1
|
||||
tsyringe: 4.10.0
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@react-pdf/fns@3.1.2': {}
|
||||
@@ -4580,6 +4757,19 @@ snapshots:
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@simplewebauthn/browser@13.2.2': {}
|
||||
|
||||
'@simplewebauthn/server@13.2.2':
|
||||
dependencies:
|
||||
'@hexagon/base64': 1.1.28
|
||||
'@levischuck/tiny-cbor': 0.2.11
|
||||
'@peculiar/asn1-android': 2.6.0
|
||||
'@peculiar/asn1-ecc': 2.6.0
|
||||
'@peculiar/asn1-rsa': 2.6.0
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
'@peculiar/x509': 1.14.3
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@swc/helpers@0.5.18':
|
||||
@@ -4948,6 +5138,12 @@ snapshots:
|
||||
|
||||
array-iterate@2.0.1: {}
|
||||
|
||||
asn1js@3.0.7:
|
||||
dependencies:
|
||||
pvtsutils: 1.3.6
|
||||
pvutils: 1.1.5
|
||||
tslib: 2.8.1
|
||||
|
||||
astro-icon@1.1.5:
|
||||
dependencies:
|
||||
'@iconify/tools': 4.2.0
|
||||
@@ -5327,7 +5523,7 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decode-named-character-reference@1.2.0:
|
||||
decode-named-character-reference@1.3.0:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
|
||||
@@ -6073,7 +6269,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
'@types/unist': 3.0.3
|
||||
decode-named-character-reference: 1.2.0
|
||||
decode-named-character-reference: 1.3.0
|
||||
devlop: 1.1.0
|
||||
mdast-util-to-string: 4.0.0
|
||||
micromark: 4.0.2
|
||||
@@ -6186,7 +6382,7 @@ snapshots:
|
||||
|
||||
micromark-core-commonmark@2.0.3:
|
||||
dependencies:
|
||||
decode-named-character-reference: 1.2.0
|
||||
decode-named-character-reference: 1.3.0
|
||||
devlop: 1.1.0
|
||||
micromark-factory-destination: 2.0.1
|
||||
micromark-factory-label: 2.0.1
|
||||
@@ -6319,7 +6515,7 @@ snapshots:
|
||||
|
||||
micromark-util-decode-string@2.0.1:
|
||||
dependencies:
|
||||
decode-named-character-reference: 1.2.0
|
||||
decode-named-character-reference: 1.3.0
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-decode-numeric-character-reference: 2.0.2
|
||||
micromark-util-symbol: 2.0.1
|
||||
@@ -6357,7 +6553,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/debug': 4.1.12
|
||||
debug: 4.4.3
|
||||
decode-named-character-reference: 1.2.0
|
||||
decode-named-character-reference: 1.3.0
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.3
|
||||
micromark-factory-space: 2.0.1
|
||||
@@ -6428,7 +6624,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/nlcst': 2.0.3
|
||||
|
||||
node-abi@3.86.0:
|
||||
node-abi@3.87.0:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
optional: true
|
||||
@@ -6614,7 +6810,7 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
mkdirp-classic: 0.5.3
|
||||
napi-build-utils: 2.0.0
|
||||
node-abi: 3.86.0
|
||||
node-abi: 3.87.0
|
||||
pump: 3.0.3
|
||||
rc: 1.2.8
|
||||
simple-get: 4.0.1
|
||||
@@ -6644,6 +6840,12 @@ snapshots:
|
||||
end-of-stream: 1.4.5
|
||||
once: 1.4.0
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
pvutils@1.1.5: {}
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
queue@6.0.2:
|
||||
@@ -6673,6 +6875,8 @@ snapshots:
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
@@ -7037,7 +7241,7 @@ snapshots:
|
||||
readable-stream: 3.6.2
|
||||
optional: true
|
||||
|
||||
tar@7.5.3:
|
||||
tar@7.5.4:
|
||||
dependencies:
|
||||
'@isaacs/fs-minipass': 4.0.1
|
||||
chownr: 3.0.0
|
||||
@@ -7074,8 +7278,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsyringe@4.10.0:
|
||||
dependencies:
|
||||
tslib: 1.14.1
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div style="position: relative; height: 100%; width: 100%;">
|
||||
<div style="position: relative; height: 100%; width: 100%">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Bar } from 'vue-chartjs';
|
||||
import { computed } from "vue";
|
||||
import { Bar } from "vue-chartjs";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
BarElement,
|
||||
@@ -14,10 +14,18 @@ import {
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarController
|
||||
} from 'chart.js';
|
||||
BarController,
|
||||
type ChartOptions,
|
||||
} from "chart.js";
|
||||
|
||||
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController);
|
||||
ChartJS.register(
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarController,
|
||||
);
|
||||
|
||||
interface ClientData {
|
||||
name: string;
|
||||
@@ -29,57 +37,61 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.clients.map(c => c.name),
|
||||
datasets: [{
|
||||
label: 'Time Tracked',
|
||||
data: props.clients.map(c => c.totalTime / (1000 * 60)), // Convert to minutes
|
||||
backgroundColor: '#6366f1',
|
||||
borderColor: '#4f46e5',
|
||||
borderWidth: 1,
|
||||
}]
|
||||
labels: props.clients.map((c) => c.name),
|
||||
datasets: [
|
||||
{
|
||||
label: "Time Tracked",
|
||||
data: props.clients.map((c) => c.totalTime / (1000 * 60)), // Convert to minutes
|
||||
backgroundColor: "#6366f1",
|
||||
borderColor: "#4f46e5",
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const chartOptions = {
|
||||
const chartOptions: ChartOptions<"bar"> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#e2e8f0',
|
||||
callback: function(value: number) {
|
||||
const hours = Math.floor(value / 60);
|
||||
const mins = value % 60;
|
||||
color: "#e2e8f0",
|
||||
callback: function (value: string | number) {
|
||||
const numValue =
|
||||
typeof value === "string" ? parseFloat(value) : value;
|
||||
const hours = Math.floor(numValue / 60);
|
||||
const mins = Math.round(numValue % 60);
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
}
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
color: '#334155'
|
||||
}
|
||||
color: "#334155",
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#e2e8f0'
|
||||
color: "#e2e8f0",
|
||||
},
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
const minutes = Math.round(context.raw);
|
||||
label: function (context) {
|
||||
const minutes = Math.round(context.raw as number);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return ` ${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -23,7 +23,6 @@ const isSubmitting = ref(false);
|
||||
const error = ref("");
|
||||
const success = ref(false);
|
||||
|
||||
// Set default dates to today
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
startDate.value = today;
|
||||
endDate.value = today;
|
||||
@@ -114,12 +113,10 @@ async function submitManualEntry() {
|
||||
if (res.ok) {
|
||||
success.value = true;
|
||||
|
||||
// Calculate duration for success message
|
||||
const start = new Date(startDateTime);
|
||||
const end = new Date(endDateTime);
|
||||
const duration = formatDuration(start, end);
|
||||
|
||||
// Reset form
|
||||
description.value = "";
|
||||
selectedClientId.value = "";
|
||||
selectedCategoryId.value = "";
|
||||
@@ -129,7 +126,6 @@ async function submitManualEntry() {
|
||||
startTime.value = "";
|
||||
endTime.value = "";
|
||||
|
||||
// Emit event and reload after a short delay
|
||||
setTimeout(() => {
|
||||
emit("entryCreated");
|
||||
window.location.reload();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div style="position: relative; height: 100%; width: 100%;">
|
||||
<div style="position: relative; height: 100%; width: 100%">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Bar } from 'vue-chartjs';
|
||||
import { computed } from "vue";
|
||||
import { Bar } from "vue-chartjs";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
BarElement,
|
||||
@@ -14,10 +14,18 @@ import {
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarController
|
||||
} from 'chart.js';
|
||||
BarController,
|
||||
type ChartOptions,
|
||||
} from "chart.js";
|
||||
|
||||
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController);
|
||||
ChartJS.register(
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarController,
|
||||
);
|
||||
|
||||
interface MemberData {
|
||||
name: string;
|
||||
@@ -29,58 +37,62 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.members.map(m => m.name),
|
||||
datasets: [{
|
||||
label: 'Time Tracked',
|
||||
data: props.members.map(m => m.totalTime / (1000 * 60)), // Convert to minutes
|
||||
backgroundColor: '#10b981',
|
||||
borderColor: '#059669',
|
||||
borderWidth: 1,
|
||||
}]
|
||||
labels: props.members.map((m) => m.name),
|
||||
datasets: [
|
||||
{
|
||||
label: "Time Tracked",
|
||||
data: props.members.map((m) => m.totalTime / (1000 * 60)), // Convert to minutes
|
||||
backgroundColor: "#10b981",
|
||||
borderColor: "#059669",
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const chartOptions = {
|
||||
indexAxis: 'y' as const,
|
||||
const chartOptions: ChartOptions<"bar"> = {
|
||||
indexAxis: "y" as const,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#e2e8f0',
|
||||
callback: function(value: number) {
|
||||
const hours = Math.floor(value / 60);
|
||||
const mins = value % 60;
|
||||
color: "#e2e8f0",
|
||||
callback: function (value: string | number) {
|
||||
const numValue =
|
||||
typeof value === "string" ? parseFloat(value) : value;
|
||||
const hours = Math.floor(numValue / 60);
|
||||
const mins = Math.round(numValue % 60);
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
}
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
color: '#334155'
|
||||
}
|
||||
color: "#334155",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: '#e2e8f0'
|
||||
color: "#e2e8f0",
|
||||
},
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
const minutes = Math.round(context.raw);
|
||||
label: function (context) {
|
||||
const minutes = Math.round(context.raw as number);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return ` ${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -31,7 +31,6 @@ function formatTime(ms: number) {
|
||||
|
||||
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
|
||||
// Calculate rounded version
|
||||
const totalMinutes = Math.round(ms / 1000 / 60);
|
||||
const roundedHours = Math.floor(totalMinutes / 60);
|
||||
const roundedMinutes = totalMinutes % 60;
|
||||
|
||||
72
src/components/auth/PasskeyLogin.vue
Normal file
72
src/components/auth/PasskeyLogin.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { Icon } from "@iconify/vue";
|
||||
import { startAuthentication } from "@simplewebauthn/browser";
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/auth/passkey/login/start");
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error("Failed to start passkey login");
|
||||
}
|
||||
|
||||
const options = await resp.json();
|
||||
|
||||
let asseResp;
|
||||
try {
|
||||
asseResp = await startAuthentication({ optionsJSON: options });
|
||||
} catch (err) {
|
||||
if ((err as any).name === "NotAllowedError") {
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
error.value = "Failed to authenticate with passkey";
|
||||
return;
|
||||
}
|
||||
|
||||
const verificationResp = await fetch("/api/auth/passkey/login/finish", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(asseResp),
|
||||
});
|
||||
|
||||
const verificationJSON = await verificationResp.json();
|
||||
if (verificationJSON.verified) {
|
||||
window.location.href = "/dashboard";
|
||||
} else {
|
||||
error.value = "Login failed. Please try again.";
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error during passkey login:", err);
|
||||
error.value = "An error occurred during login";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-secondary w-full"
|
||||
@click="handlePasskeyLogin"
|
||||
:disabled="loading"
|
||||
>
|
||||
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
||||
<Icon v-else icon="heroicons:finger-print" class="w-5 h-5 mr-2" />
|
||||
Sign in with Passkey
|
||||
</button>
|
||||
|
||||
<div v-if="error" role="alert" class="alert alert-error mt-4">
|
||||
<Icon icon="heroicons:exclamation-circle" class="w-6 h-6" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
256
src/components/settings/ApiTokenManager.vue
Normal file
256
src/components/settings/ApiTokenManager.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { Icon } from "@iconify/vue";
|
||||
|
||||
interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
initialTokens: ApiToken[];
|
||||
}>();
|
||||
|
||||
const tokens = ref<ApiToken[]>(props.initialTokens);
|
||||
const createModalOpen = ref(false);
|
||||
const showTokenModalOpen = ref(false);
|
||||
const newTokenName = ref("");
|
||||
const newTokenValue = ref("");
|
||||
const loading = ref(false);
|
||||
const isMounted = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
});
|
||||
|
||||
function formatDate(dateString: string | null) {
|
||||
if (!dateString) return "Never";
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
}
|
||||
|
||||
async function createToken() {
|
||||
if (!newTokenName.value) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await fetch("/api/user/tokens", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name: newTokenName.value }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
const { token, ...tokenMeta } = data;
|
||||
|
||||
tokens.value.unshift({
|
||||
id: tokenMeta.id,
|
||||
name: tokenMeta.name,
|
||||
lastUsedAt: tokenMeta.lastUsedAt,
|
||||
createdAt: tokenMeta.createdAt,
|
||||
});
|
||||
|
||||
newTokenValue.value = token;
|
||||
createModalOpen.value = false;
|
||||
showTokenModalOpen.value = true;
|
||||
newTokenName.value = "";
|
||||
} else {
|
||||
alert("Failed to create token");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating token:", error);
|
||||
alert("An error occurred");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteToken(id: string) {
|
||||
if (
|
||||
!confirm(
|
||||
"Are you sure you want to revoke this token? Any applications using it will stop working.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/user/tokens/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
tokens.value = tokens.value.filter((t) => t.id !== id);
|
||||
} else {
|
||||
alert("Failed to delete token");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting token:", error);
|
||||
alert("An error occurred");
|
||||
}
|
||||
}
|
||||
|
||||
function copyToken() {
|
||||
navigator.clipboard.writeText(newTokenValue.value);
|
||||
}
|
||||
|
||||
function closeShowTokenModal() {
|
||||
showTokenModalOpen.value = false;
|
||||
newTokenValue.value = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="card-title text-lg sm:text-xl">
|
||||
<Icon
|
||||
icon="heroicons:code-bracket-square"
|
||||
class="w-5 h-5 sm:w-6 sm:h-6"
|
||||
/>
|
||||
API Tokens
|
||||
</h2>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="createModalOpen = true"
|
||||
>
|
||||
<Icon icon="heroicons:plus" class="w-4 h-4" />
|
||||
Create Token
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Last Used</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="tokens.length === 0">
|
||||
<td colspan="4" class="text-center text-base-content/60 py-4">
|
||||
No API tokens found. Create one to access the API.
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else v-for="token in tokens" :key="token.id">
|
||||
<td class="font-medium">{{ token.name }}</td>
|
||||
<td class="text-sm">
|
||||
<span v-if="isMounted">{{
|
||||
formatDate(token.lastUsedAt)
|
||||
}}</span>
|
||||
<span v-else>{{ token.lastUsedAt || "Never" }}</span>
|
||||
</td>
|
||||
<td class="text-sm">
|
||||
<span v-if="isMounted">{{
|
||||
formatDate(token.createdAt)
|
||||
}}</span>
|
||||
<span v-else>{{ token.createdAt }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
@click="deleteToken(token.id)"
|
||||
>
|
||||
<Icon icon="heroicons:trash" class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Token Modal -->
|
||||
<dialog class="modal" :class="{ 'modal-open': createModalOpen }">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Create API Token</h3>
|
||||
<p class="py-4 text-sm text-base-content/70">
|
||||
API tokens allow you to authenticate with the API programmatically.
|
||||
Give your token a descriptive name.
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="createToken" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium">Token Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="newTokenName"
|
||||
placeholder="e.g. CI/CD Pipeline"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="createModalOpen = false">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
<span
|
||||
v-if="loading"
|
||||
class="loading loading-spinner loading-sm"
|
||||
></span>
|
||||
Generate Token
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form
|
||||
method="dialog"
|
||||
class="modal-backdrop"
|
||||
@click="createModalOpen = false"
|
||||
>
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Show Token Modal -->
|
||||
<dialog class="modal" :class="{ 'modal-open': showTokenModalOpen }">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg text-success flex items-center gap-2">
|
||||
<Icon icon="heroicons:check-circle" class="w-6 h-6" />
|
||||
Token Created
|
||||
</h3>
|
||||
<p class="py-4">
|
||||
Make sure to copy your personal access token now. You won't be able to
|
||||
see it again!
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group"
|
||||
>
|
||||
<span>{{ newTokenValue }}</span>
|
||||
<button
|
||||
class="absolute top-2 right-2 btn btn-xs btn-ghost opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click="copyToken"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<Icon icon="heroicons:clipboard" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-primary" @click="closeShowTokenModal">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop" @click="closeShowTokenModal">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
169
src/components/settings/PasskeyManager.vue
Normal file
169
src/components/settings/PasskeyManager.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { Icon } from "@iconify/vue";
|
||||
import { startRegistration } from "@simplewebauthn/browser";
|
||||
|
||||
interface Passkey {
|
||||
id: string;
|
||||
deviceType: string;
|
||||
backedUp: boolean;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
initialPasskeys: Passkey[];
|
||||
}>();
|
||||
|
||||
const passkeys = ref<Passkey[]>(props.initialPasskeys);
|
||||
const loading = ref(false);
|
||||
const isMounted = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
});
|
||||
|
||||
function formatDate(dateString: string | null) {
|
||||
if (!dateString) return "N/A";
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
}
|
||||
|
||||
async function registerPasskey() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const resp = await fetch("/api/auth/passkey/register/start");
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error("Failed to start registration");
|
||||
}
|
||||
|
||||
const options = await resp.json();
|
||||
|
||||
let attResp;
|
||||
try {
|
||||
attResp = await startRegistration({ optionsJSON: options });
|
||||
} catch (error) {
|
||||
if ((error as any).name === "NotAllowedError") {
|
||||
return;
|
||||
}
|
||||
console.error(error);
|
||||
alert("Failed to register passkey: " + (error as any).message);
|
||||
return;
|
||||
}
|
||||
|
||||
const verificationResp = await fetch("/api/auth/passkey/register/finish", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(attResp),
|
||||
});
|
||||
|
||||
const verificationJSON = await verificationResp.json();
|
||||
if (verificationJSON.verified) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert("Passkey registration failed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error registering passkey:", error);
|
||||
alert("An error occurred");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePasskey(id: string) {
|
||||
if (!confirm("Are you sure you want to remove this passkey?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/auth/passkey/delete?id=${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
passkeys.value = passkeys.value.filter((pk) => pk.id !== id);
|
||||
} else {
|
||||
alert("Failed to delete passkey");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting passkey:", error);
|
||||
alert("An error occurred");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="card-title text-lg sm:text-xl">
|
||||
<Icon icon="heroicons:finger-print" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
Passkeys
|
||||
</h2>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="registerPasskey"
|
||||
:disabled="loading"
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="loading loading-spinner loading-xs"
|
||||
></span>
|
||||
<Icon v-else icon="heroicons:plus" class="w-4 h-4" />
|
||||
Add Passkey
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device Type</th>
|
||||
<th>Last Used</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="passkeys.length === 0">
|
||||
<td colspan="4" class="text-center text-base-content/60 py-4">
|
||||
No passkeys found. Add one to sign in without a password.
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else v-for="pk in passkeys" :key="pk.id">
|
||||
<td class="font-medium">
|
||||
{{
|
||||
pk.deviceType === "singleDevice"
|
||||
? "This Device"
|
||||
: "Cross-Platform (Phone/Key)"
|
||||
}}
|
||||
<span v-if="pk.backedUp" class="badge badge-xs badge-info ml-2"
|
||||
>Backed Up</span
|
||||
>
|
||||
</td>
|
||||
<td class="text-sm">
|
||||
<span v-if="isMounted">
|
||||
{{ pk.lastUsedAt ? formatDate(pk.lastUsedAt) : "Never" }}
|
||||
</span>
|
||||
<span v-else>{{ pk.lastUsedAt || "Never" }}</span>
|
||||
</td>
|
||||
<td class="text-sm">
|
||||
<span v-if="isMounted">{{ formatDate(pk.createdAt) }}</span>
|
||||
<span v-else>{{ pk.createdAt || "N/A" }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
@click="deletePasskey(pk.id)"
|
||||
>
|
||||
<Icon icon="heroicons:trash" class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
130
src/components/settings/PasswordForm.vue
Normal file
130
src/components/settings/PasswordForm.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
const currentPassword = ref('');
|
||||
const newPassword = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const loading = ref(false);
|
||||
const message = ref<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
async function changePassword() {
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
message.value = { type: 'error', text: 'New passwords do not match' };
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.value.length < 8) {
|
||||
message.value = { type: 'error', text: 'Password must be at least 8 characters' };
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
message.value = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
currentPassword: currentPassword.value,
|
||||
newPassword: newPassword.value,
|
||||
confirmPassword: confirmPassword.value,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
message.value = { type: 'success', text: 'Password changed successfully!' };
|
||||
currentPassword.value = '';
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
|
||||
setTimeout(() => {
|
||||
message.value = null;
|
||||
}, 3000);
|
||||
} else {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
message.value = { type: 'error', text: data.error || 'Failed to change password' };
|
||||
}
|
||||
} catch (error) {
|
||||
message.value = { type: 'error', text: 'An error occurred' };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Success/Error Message Display -->
|
||||
<div v-if="message" :class="['alert mb-6', message.type === 'success' ? 'alert-success' : 'alert-error']">
|
||||
<Icon :icon="message.type === 'success' ? 'heroicons:check-circle' : 'heroicons:exclamation-circle'" class="w-6 h-6 shrink-0" />
|
||||
<span>{{ message.text }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||
<Icon icon="heroicons:key" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
Change Password
|
||||
</h2>
|
||||
|
||||
<form @submit.prevent="changePassword" class="space-y-5">
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
v-model="currentPassword"
|
||||
placeholder="Enter current password"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">New Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
v-model="newPassword"
|
||||
placeholder="Enter new password"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
<div class="label pt-2">
|
||||
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Minimum 8 characters</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">Confirm New Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
v-model="confirmPassword"
|
||||
placeholder="Confirm new password"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="submit" class="btn btn-primary w-full sm:w-auto" :disabled="loading">
|
||||
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
||||
<Icon v-else icon="heroicons:lock-closed" class="w-5 h-5" />
|
||||
Update Password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
135
src/components/settings/ProfileForm.vue
Normal file
135
src/components/settings/ProfileForm.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { Icon } from "@iconify/vue";
|
||||
|
||||
const props = defineProps<{
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const name = ref(props.user.name);
|
||||
const loading = ref(false);
|
||||
const message = ref<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
async function updateProfile() {
|
||||
loading.value = true;
|
||||
message.value = null;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/user/update-profile", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name: name.value }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
message.value = {
|
||||
type: "success",
|
||||
text: "Profile updated successfully!",
|
||||
};
|
||||
setTimeout(() => {
|
||||
message.value = null;
|
||||
}, 3000);
|
||||
} else {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
message.value = {
|
||||
type: "error",
|
||||
text: data.error || "Failed to update profile",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
message.value = { type: "error", text: "An error occurred" };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Success/Error Message Display -->
|
||||
<div
|
||||
v-if="message"
|
||||
:class="[
|
||||
'alert mb-6',
|
||||
message.type === 'success' ? 'alert-success' : 'alert-error',
|
||||
]"
|
||||
>
|
||||
<Icon
|
||||
:icon="
|
||||
message.type === 'success'
|
||||
? 'heroicons:check-circle'
|
||||
: 'heroicons:exclamation-circle'
|
||||
"
|
||||
class="w-6 h-6 shrink-0"
|
||||
/>
|
||||
<span>{{ message.text }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||
<Icon icon="heroicons:user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
Profile Information
|
||||
</h2>
|
||||
|
||||
<form @submit.prevent="updateProfile" class="space-y-5">
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base"
|
||||
>Full Name</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="name"
|
||||
placeholder="Your full name"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base"
|
||||
>Email</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
:value="props.user.email"
|
||||
class="input input-bordered w-full"
|
||||
disabled
|
||||
/>
|
||||
<div class="label pt-2">
|
||||
<span
|
||||
class="label-text-alt text-base-content/60 text-xs sm:text-sm"
|
||||
>Email cannot be changed</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-full sm:w-auto"
|
||||
:disabled="loading"
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="loading loading-spinner loading-sm"
|
||||
></span>
|
||||
<Icon v-else icon="heroicons:check" class="w-5 h-5" />
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,7 +4,6 @@ import * as schema from "./schema";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
// Define the database type based on the schema
|
||||
type Database = ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
let _db: Database | null = null;
|
||||
|
||||
@@ -270,19 +270,19 @@ export const invoices = sqliteTable(
|
||||
organizationId: text("organization_id").notNull(),
|
||||
clientId: text("client_id").notNull(),
|
||||
number: text("number").notNull(),
|
||||
type: text("type").notNull().default("invoice"), // 'invoice' or 'quote'
|
||||
status: text("status").notNull().default("draft"), // 'draft', 'sent', 'paid', 'void', 'accepted', 'declined'
|
||||
type: text("type").notNull().default("invoice"),
|
||||
status: text("status").notNull().default("draft"),
|
||||
issueDate: integer("issue_date", { mode: "timestamp" }).notNull(),
|
||||
dueDate: integer("due_date", { mode: "timestamp" }).notNull(),
|
||||
notes: text("notes"),
|
||||
currency: text("currency").default("USD").notNull(),
|
||||
subtotal: integer("subtotal").notNull().default(0), // in cents
|
||||
subtotal: integer("subtotal").notNull().default(0),
|
||||
discountValue: real("discount_value").default(0),
|
||||
discountType: text("discount_type").default("percentage"), // 'percentage' or 'fixed'
|
||||
discountAmount: integer("discount_amount").default(0), // in cents
|
||||
taxRate: real("tax_rate").default(0), // percentage
|
||||
taxAmount: integer("tax_amount").notNull().default(0), // in cents
|
||||
total: integer("total").notNull().default(0), // in cents
|
||||
discountType: text("discount_type").default("percentage"),
|
||||
discountAmount: integer("discount_amount").default(0),
|
||||
taxRate: real("tax_rate").default(0),
|
||||
taxAmount: integer("tax_amount").notNull().default(0),
|
||||
total: integer("total").notNull().default(0),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||
() => new Date(),
|
||||
),
|
||||
@@ -312,8 +312,8 @@ export const invoiceItems = sqliteTable(
|
||||
invoiceId: text("invoice_id").notNull(),
|
||||
description: text("description").notNull(),
|
||||
quantity: real("quantity").notNull().default(1),
|
||||
unitPrice: integer("unit_price").notNull().default(0), // in cents
|
||||
amount: integer("amount").notNull().default(0), // in cents
|
||||
unitPrice: integer("unit_price").notNull().default(0),
|
||||
amount: integer("amount").notNull().default(0),
|
||||
},
|
||||
(table: any) => ({
|
||||
invoiceFk: foreignKey({
|
||||
@@ -323,3 +323,36 @@ export const invoiceItems = sqliteTable(
|
||||
invoiceIdIdx: index("invoice_items_invoice_id_idx").on(table.invoiceId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const passkeys = sqliteTable(
|
||||
"passkeys",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull(),
|
||||
publicKey: text("public_key").notNull(),
|
||||
counter: integer("counter").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
|
||||
transports: text("transports"),
|
||||
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||
() => new Date(),
|
||||
),
|
||||
},
|
||||
(table: any) => ({
|
||||
userFk: foreignKey({
|
||||
columns: [table.userId],
|
||||
foreignColumns: [users.id],
|
||||
}),
|
||||
userIdIdx: index("passkeys_user_id_idx").on(table.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const passkeyChallenges = sqliteTable("passkey_challenges", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
challenge: text("challenge").notNull().unique(),
|
||||
userId: text("user_id"),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ if (!user) {
|
||||
return Astro.redirect('/login');
|
||||
}
|
||||
|
||||
// Get user's team memberships
|
||||
const userMemberships = await db.select({
|
||||
membership: members,
|
||||
organization: organizations,
|
||||
@@ -28,7 +27,6 @@ const userMemberships = await db.select({
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
// Get current team from cookie or use first membership
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id;
|
||||
const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId);
|
||||
---
|
||||
|
||||
@@ -24,7 +24,6 @@ export async function validateApiToken(token: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last used at
|
||||
await db
|
||||
.update(apiTokens)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)"
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
// Calculate rounded version for easy reading
|
||||
const totalMinutes = Math.round(ms / 1000 / 60);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
35
src/pages/api/auth/passkey/delete/index.ts
Normal file
35
src/pages/api/auth/passkey/delete/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../../../db";
|
||||
import { passkeys } from "../../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export const DELETE: APIRoute = async ({ request, locals }) => {
|
||||
const user = locals.user;
|
||||
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get("id");
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: "Passkey ID is required" }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(passkeys)
|
||||
.where(and(eq(passkeys.id, id), eq(passkeys.userId, user.id)));
|
||||
|
||||
return new Response(JSON.stringify({ success: true }));
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: "Failed to delete passkey" }), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
};
|
||||
102
src/pages/api/auth/passkey/login/finish.ts
Normal file
102
src/pages/api/auth/passkey/login/finish.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
|
||||
import { db } from "../../../../../db";
|
||||
import { users, passkeys, passkeyChallenges } from "../../../../../db/schema";
|
||||
import { eq, and, gt } from "drizzle-orm";
|
||||
import { createSession } from "../../../../../lib/auth";
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies }) => {
|
||||
const body = await request.json();
|
||||
const { id } = body;
|
||||
|
||||
const passkey = await db.query.passkeys.findFirst({
|
||||
where: eq(passkeys.id, id),
|
||||
});
|
||||
|
||||
if (!passkey) {
|
||||
return new Response(JSON.stringify({ error: "Passkey not found" }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, passkey.userId),
|
||||
});
|
||||
|
||||
if (!user) return new Response(null, { status: 400 });
|
||||
|
||||
const clientDataJSON = Buffer.from(
|
||||
body.response.clientDataJSON,
|
||||
"base64url",
|
||||
).toString("utf-8");
|
||||
const clientData = JSON.parse(clientDataJSON);
|
||||
const challenge = clientData.challenge;
|
||||
|
||||
const dbChallenge = await db.query.passkeyChallenges.findFirst({
|
||||
where: and(
|
||||
eq(passkeyChallenges.challenge, challenge),
|
||||
gt(passkeyChallenges.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
if (!dbChallenge) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid or expired challenge" }),
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: body,
|
||||
expectedChallenge: challenge as string,
|
||||
expectedOrigin: new URL(request.url).origin,
|
||||
expectedRPID: new URL(request.url).hostname,
|
||||
credential: {
|
||||
id: passkey.id,
|
||||
publicKey: new Uint8Array(Buffer.from(passkey.publicKey, "base64")),
|
||||
counter: passkey.counter,
|
||||
transports: passkey.transports
|
||||
? JSON.parse(passkey.transports)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (verification.verified) {
|
||||
const { authenticationInfo } = verification;
|
||||
|
||||
await db
|
||||
.update(passkeys)
|
||||
.set({
|
||||
counter: authenticationInfo.newCounter,
|
||||
lastUsedAt: new Date(),
|
||||
})
|
||||
.where(eq(passkeys.id, passkey.id));
|
||||
|
||||
const { sessionId, expiresAt } = await createSession(user.id);
|
||||
|
||||
cookies.set("session_id", sessionId, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: import.meta.env.PROD,
|
||||
sameSite: "lax",
|
||||
expires: expiresAt,
|
||||
});
|
||||
|
||||
await db
|
||||
.delete(passkeyChallenges)
|
||||
.where(eq(passkeyChallenges.challenge, challenge));
|
||||
|
||||
return new Response(JSON.stringify({ verified: true }));
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ verified: false }), { status: 400 });
|
||||
};
|
||||
18
src/pages/api/auth/passkey/login/start.ts
Normal file
18
src/pages/api/auth/passkey/login/start.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { generateAuthenticationOptions } from "@simplewebauthn/server";
|
||||
import { db } from "../../../../../db";
|
||||
import { passkeyChallenges } from "../../../../../db/schema";
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: new URL(request.url).hostname,
|
||||
userVerification: "preferred",
|
||||
});
|
||||
|
||||
await db.insert(passkeyChallenges).values({
|
||||
challenge: options.challenge,
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(options));
|
||||
};
|
||||
81
src/pages/api/auth/passkey/register/finish.ts
Normal file
81
src/pages/api/auth/passkey/register/finish.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
||||
import { db } from "../../../../../db";
|
||||
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
|
||||
import { eq, and, gt } from "drizzle-orm";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const user = locals.user;
|
||||
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const clientDataJSON = Buffer.from(
|
||||
body.response.clientDataJSON,
|
||||
"base64url",
|
||||
).toString("utf-8");
|
||||
const clientData = JSON.parse(clientDataJSON);
|
||||
const challenge = clientData.challenge;
|
||||
|
||||
const dbChallenge = await db.query.passkeyChallenges.findFirst({
|
||||
where: and(
|
||||
eq(passkeyChallenges.challenge, challenge),
|
||||
eq(passkeyChallenges.userId, user.id),
|
||||
gt(passkeyChallenges.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
if (!dbChallenge) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid or expired challenge" }),
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: body,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: new URL(request.url).origin,
|
||||
expectedRPID: new URL(request.url).hostname,
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (verification.verified && verification.registrationInfo) {
|
||||
const { registrationInfo } = verification;
|
||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||
registrationInfo;
|
||||
|
||||
await db.insert(passkeys).values({
|
||||
id: credential.id,
|
||||
userId: user.id,
|
||||
publicKey: Buffer.from(credential.publicKey).toString("base64"),
|
||||
counter: credential.counter,
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
transports: body.response.transports
|
||||
? JSON.stringify(body.response.transports)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await db
|
||||
.delete(passkeyChallenges)
|
||||
.where(eq(passkeyChallenges.challenge, challenge));
|
||||
|
||||
return new Response(JSON.stringify({ verified: true }));
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ verified: false }), { status: 400 });
|
||||
};
|
||||
44
src/pages/api/auth/passkey/register/start.ts
Normal file
44
src/pages/api/auth/passkey/register/start.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { generateRegistrationOptions } from "@simplewebauthn/server";
|
||||
import { db } from "../../../../../db";
|
||||
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const GET: APIRoute = async ({ request, locals }) => {
|
||||
const user = locals.user;
|
||||
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const userPasskeys = await db.query.passkeys.findMany({
|
||||
where: eq(passkeys.userId, user.id),
|
||||
});
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: "Chronus",
|
||||
rpID: new URL(request.url).hostname,
|
||||
userName: user.email,
|
||||
attestationType: "none",
|
||||
excludeCredentials: userPasskeys.map((passkey) => ({
|
||||
id: passkey.id,
|
||||
transports: passkey.transports
|
||||
? JSON.parse(passkey.transports)
|
||||
: undefined,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred",
|
||||
},
|
||||
});
|
||||
|
||||
await db.insert(passkeyChallenges).values({
|
||||
challenge: options.challenge,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(options));
|
||||
};
|
||||
@@ -14,7 +14,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
||||
return new Response("Invoice ID required", { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch invoice to verify existence
|
||||
const invoice = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Verify membership
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(members)
|
||||
@@ -48,7 +46,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate next invoice number
|
||||
const lastInvoice = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
@@ -74,11 +71,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert quote to invoice:
|
||||
// 1. Change type to 'invoice'
|
||||
// 2. Set status to 'draft' (so user can review before sending)
|
||||
// 3. Update number to next invoice sequence
|
||||
// 4. Update issue date to today
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
|
||||
@@ -20,7 +20,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// Fetch invoice with related data
|
||||
const invoiceResult = await db
|
||||
.select({
|
||||
invoice: invoices,
|
||||
@@ -39,7 +38,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
||||
|
||||
const { invoice, client, organization } = invoiceResult;
|
||||
|
||||
// Verify access
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(members)
|
||||
@@ -55,7 +53,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
// Fetch items
|
||||
const items = await db
|
||||
.select()
|
||||
.from(invoiceItems)
|
||||
@@ -66,8 +63,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
||||
return new Response("Client not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Generate PDF using Vue PDF
|
||||
// Suppress verbose logging from PDF renderer
|
||||
const originalConsoleLog = console.log;
|
||||
const originalConsoleWarn = console.warn;
|
||||
console.log = () => {};
|
||||
@@ -83,7 +78,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
||||
|
||||
const stream = await renderToStream(pdfDocument);
|
||||
|
||||
// Restore console.log
|
||||
console.log = originalConsoleLog;
|
||||
console.warn = originalConsoleWarn;
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ export const POST: APIRoute = async ({
|
||||
const quantity = parseFloat(quantityStr);
|
||||
const unitPriceMajor = parseFloat(unitPriceStr);
|
||||
|
||||
// Convert to cents
|
||||
const unitPrice = Math.round(unitPriceMajor * 100);
|
||||
const amount = Math.round(quantity * unitPrice);
|
||||
|
||||
@@ -77,7 +76,6 @@ export const POST: APIRoute = async ({
|
||||
amount,
|
||||
});
|
||||
|
||||
// Update invoice totals
|
||||
await recalculateInvoiceTotals(invoiceId);
|
||||
|
||||
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||
|
||||
@@ -20,7 +20,6 @@ export const POST: APIRoute = async ({
|
||||
return new Response("Invoice ID required", { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch invoice to verify existence and check status
|
||||
const invoice = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({
|
||||
return new Response("Invoice not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Verify membership
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(members)
|
||||
@@ -47,7 +45,6 @@ export const POST: APIRoute = async ({
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// Only allow editing if draft
|
||||
if (invoice.status !== "draft") {
|
||||
return new Response("Cannot edit a finalized invoice", { status: 400 });
|
||||
}
|
||||
@@ -59,7 +56,6 @@ export const POST: APIRoute = async ({
|
||||
return new Response("Item ID required", { status: 400 });
|
||||
}
|
||||
|
||||
// Verify item belongs to invoice
|
||||
const item = await db
|
||||
.select()
|
||||
.from(invoiceItems)
|
||||
@@ -73,7 +69,6 @@ export const POST: APIRoute = async ({
|
||||
try {
|
||||
await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId));
|
||||
|
||||
// Update invoice totals
|
||||
await recalculateInvoiceTotals(invoiceId);
|
||||
|
||||
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||
|
||||
@@ -35,7 +35,6 @@ export const POST: APIRoute = async ({
|
||||
return new Response("Invalid status", { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch invoice to verify existence and check ownership
|
||||
const invoice = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
@@ -46,7 +45,6 @@ export const POST: APIRoute = async ({
|
||||
return new Response("Invoice not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Verify membership
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(members)
|
||||
|
||||
@@ -20,7 +20,6 @@ export const POST: APIRoute = async ({
|
||||
return new Response("Invoice ID required", { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch invoice to verify existence
|
||||
const invoice = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({
|
||||
return new Response("Invoice not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Verify membership
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(members)
|
||||
|
||||
137
src/pages/api/reports/export.ts
Normal file
137
src/pages/api/reports/export.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { timeEntries, members, users, clients, categories } from '../../../db/schema';
|
||||
import { eq, and, gte, lte, desc } from 'drizzle-orm';
|
||||
|
||||
export const GET: APIRoute = async ({ request, locals, cookies }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) {
|
||||
return new Response('No organization found', { status: 404 });
|
||||
}
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
|
||||
const url = new URL(request.url);
|
||||
const selectedMemberId = url.searchParams.get('member') || '';
|
||||
const selectedCategoryId = url.searchParams.get('category') || '';
|
||||
const selectedClientId = url.searchParams.get('client') || '';
|
||||
const timeRange = url.searchParams.get('range') || 'week';
|
||||
const customFrom = url.searchParams.get('from');
|
||||
const customTo = url.searchParams.get('to');
|
||||
|
||||
const now = new Date();
|
||||
let startDate = new Date();
|
||||
let endDate = new Date();
|
||||
|
||||
switch (timeRange) {
|
||||
case 'today':
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
case 'week':
|
||||
startDate.setDate(now.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
startDate.setMonth(now.getMonth() - 1);
|
||||
break;
|
||||
case 'mtd':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
break;
|
||||
case 'ytd':
|
||||
startDate = new Date(now.getFullYear(), 0, 1);
|
||||
break;
|
||||
case 'last-month':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
||||
break;
|
||||
case 'custom':
|
||||
if (customFrom) {
|
||||
const parts = customFrom.split('-');
|
||||
startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0);
|
||||
}
|
||||
if (customTo) {
|
||||
const parts = customTo.split('-');
|
||||
endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 23, 59, 59, 999);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
eq(timeEntries.organizationId, userMembership.organizationId),
|
||||
gte(timeEntries.startTime, startDate),
|
||||
lte(timeEntries.startTime, endDate),
|
||||
];
|
||||
|
||||
if (selectedMemberId) {
|
||||
conditions.push(eq(timeEntries.userId, selectedMemberId));
|
||||
}
|
||||
|
||||
if (selectedCategoryId) {
|
||||
conditions.push(eq(timeEntries.categoryId, selectedCategoryId));
|
||||
}
|
||||
|
||||
if (selectedClientId) {
|
||||
conditions.push(eq(timeEntries.clientId, selectedClientId));
|
||||
}
|
||||
|
||||
const entries = await db.select({
|
||||
entry: timeEntries,
|
||||
user: users,
|
||||
client: clients,
|
||||
category: categories,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.innerJoin(users, eq(timeEntries.userId, users.id))
|
||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(timeEntries.startTime))
|
||||
.all();
|
||||
|
||||
// Generate CSV
|
||||
const headers = ['Date', 'Start Time', 'End Time', 'Duration (h)', 'Member', 'Client', 'Category', 'Description'];
|
||||
const rows = entries.map(e => {
|
||||
const start = e.entry.startTime;
|
||||
const end = e.entry.endTime;
|
||||
|
||||
let duration = 0;
|
||||
if (end) {
|
||||
duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Hours
|
||||
}
|
||||
|
||||
return [
|
||||
start.toLocaleDateString(),
|
||||
start.toLocaleTimeString(),
|
||||
end ? end.toLocaleTimeString() : '',
|
||||
end ? duration.toFixed(2) : 'Running',
|
||||
`"${(e.user.name || '').replace(/"/g, '""')}"`,
|
||||
`"${(e.client.name || '').replace(/"/g, '""')}"`,
|
||||
`"${(e.category.name || '').replace(/"/g, '""')}"`,
|
||||
`"${(e.entry.description || '').replace(/"/g, '""')}"`
|
||||
].join(',');
|
||||
});
|
||||
|
||||
const csvContent = [headers.join(','), ...rows].join('\n');
|
||||
|
||||
return new Response(csvContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-Disposition': `attachment; filename="time-entries-${startDate.toISOString().split('T')[0]}-to-${endDate.toISOString().split('T')[0]}.csv"`,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,61 +1,104 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { users } from '../../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import { users } from "../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const user = locals.user;
|
||||
const contentType = request.headers.get("content-type");
|
||||
const isJson = contentType?.includes("application/json");
|
||||
|
||||
if (!user) {
|
||||
return redirect('/login');
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const currentPassword = formData.get('currentPassword') as string;
|
||||
const newPassword = formData.get('newPassword') as string;
|
||||
const confirmPassword = formData.get('confirmPassword') as string;
|
||||
let currentPassword, newPassword, confirmPassword;
|
||||
|
||||
if (isJson) {
|
||||
const body = await request.json();
|
||||
currentPassword = body.currentPassword;
|
||||
newPassword = body.newPassword;
|
||||
confirmPassword = body.confirmPassword;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
currentPassword = formData.get("currentPassword") as string;
|
||||
newPassword = formData.get("newPassword") as string;
|
||||
confirmPassword = formData.get("confirmPassword") as string;
|
||||
}
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
return new Response('All fields are required', { status: 400 });
|
||||
const msg = "All fields are required";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
return new Response('New passwords do not match', { status: 400 });
|
||||
const msg = "New passwords do not match";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return new Response('Password must be at least 8 characters', { status: 400 });
|
||||
const msg = "Password must be at least 8 characters";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current user from database
|
||||
const dbUser = await db.select()
|
||||
const dbUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, user.id))
|
||||
.get();
|
||||
|
||||
if (!dbUser) {
|
||||
return new Response('User not found', { status: 404 });
|
||||
const msg = "User not found";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 404 });
|
||||
return new Response(msg, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const passwordMatch = await bcrypt.compare(currentPassword, dbUser.passwordHash);
|
||||
const passwordMatch = await bcrypt.compare(
|
||||
currentPassword,
|
||||
dbUser.passwordHash,
|
||||
);
|
||||
if (!passwordMatch) {
|
||||
return new Response('Current password is incorrect', { status: 400 });
|
||||
const msg = "Current password is incorrect";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Update password
|
||||
await db.update(users)
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash: hashedPassword })
|
||||
.where(eq(users.id, user.id))
|
||||
.run();
|
||||
|
||||
return redirect('/dashboard/settings?success=password');
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||
}
|
||||
return redirect("/dashboard/settings?success=password");
|
||||
} catch (error) {
|
||||
console.error('Error changing password:', error);
|
||||
return new Response('Failed to change password', { status: 500 });
|
||||
console.error("Error changing password:", error);
|
||||
const msg = "Failed to change password";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 500 });
|
||||
return new Response(msg, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,8 +12,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const name = formData.get("name")?.toString();
|
||||
let name: string | undefined;
|
||||
|
||||
const contentType = request.headers.get("content-type");
|
||||
if (contentType?.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
name = body.name;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
name = formData.get("name")?.toString();
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return new Response(JSON.stringify({ error: "Name is required" }), {
|
||||
|
||||
@@ -1,30 +1,58 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { users } from '../../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import { users } from "../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const user = locals.user;
|
||||
const contentType = request.headers.get("content-type");
|
||||
const isJson = contentType?.includes("application/json");
|
||||
|
||||
if (!user) {
|
||||
return redirect('/login');
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
let name: string | undefined;
|
||||
|
||||
if (isJson) {
|
||||
const body = await request.json();
|
||||
name = body.name;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
name = formData.get("name") as string;
|
||||
}
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return new Response('Name is required', { status: 400 });
|
||||
const msg = "Name is required";
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
}
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.update(users)
|
||||
await db
|
||||
.update(users)
|
||||
.set({ name: name.trim() })
|
||||
.where(eq(users.id, user.id))
|
||||
.run();
|
||||
|
||||
return redirect('/dashboard/settings?success=profile');
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||
}
|
||||
|
||||
return redirect("/dashboard/settings?success=profile");
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
return new Response('Failed to update profile', { status: 500 });
|
||||
console.error("Error updating profile:", error);
|
||||
const msg = "Failed to update profile";
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 500 });
|
||||
}
|
||||
return new Response(msg, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -294,7 +294,7 @@ const isDraft = invoice.status === 'draft';
|
||||
<span class="text-base-content/60">Subtotal</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
|
||||
</div>
|
||||
{(invoice.discountAmount > 0) && (
|
||||
{(invoice.discountAmount && invoice.discountAmount > 0) && (
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-base-content/60">
|
||||
Discount
|
||||
|
||||
@@ -52,6 +52,8 @@ const selectedMemberId = url.searchParams.get('member') || '';
|
||||
const selectedCategoryId = url.searchParams.get('category') || '';
|
||||
const selectedClientId = url.searchParams.get('client') || '';
|
||||
const timeRange = url.searchParams.get('range') || 'week';
|
||||
const customFrom = url.searchParams.get('from');
|
||||
const customTo = url.searchParams.get('to');
|
||||
|
||||
const now = new Date();
|
||||
let startDate = new Date();
|
||||
@@ -78,6 +80,16 @@ switch (timeRange) {
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
||||
break;
|
||||
case 'custom':
|
||||
if (customFrom) {
|
||||
const parts = customFrom.split('-');
|
||||
startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0);
|
||||
}
|
||||
if (customTo) {
|
||||
const parts = customTo.split('-');
|
||||
endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 23, 59, 59, 999);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
@@ -250,6 +262,7 @@ function getTimeRangeLabel(range: string) {
|
||||
case 'mtd': return 'Month to Date';
|
||||
case 'ytd': return 'Year to Date';
|
||||
case 'last-month': return 'Last Month';
|
||||
case 'custom': return 'Custom Range';
|
||||
default: return 'Last 7 Days';
|
||||
}
|
||||
}
|
||||
@@ -273,9 +286,39 @@ function getTimeRangeLabel(range: string) {
|
||||
<option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option>
|
||||
<option value="ytd" selected={timeRange === 'ytd'}>Year to Date</option>
|
||||
<option value="last-month" selected={timeRange === 'last-month'}>Last Month</option>
|
||||
<option value="custom" selected={timeRange === 'custom'}>Custom Range</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{timeRange === 'custom' && (
|
||||
<>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">From Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="from"
|
||||
class="input input-bordered w-full"
|
||||
value={customFrom || (startDate.getFullYear() + '-' + String(startDate.getMonth() + 1).padStart(2, '0') + '-' + String(startDate.getDate()).padStart(2, '0'))}
|
||||
onchange="this.form.submit()"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">To Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="to"
|
||||
class="input input-bordered w-full"
|
||||
value={customTo || (endDate.getFullYear() + '-' + String(endDate.getMonth() + 1).padStart(2, '0') + '-' + String(endDate.getDate()).padStart(2, '0'))}
|
||||
onchange="this.form.submit()"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Team Member</span>
|
||||
@@ -328,7 +371,7 @@ function getTimeRangeLabel(range: string) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
select {
|
||||
select, input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -511,7 +554,7 @@ function getTimeRangeLabel(range: string) {
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
<CategoryChart
|
||||
client:load
|
||||
client:visible
|
||||
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.category.name,
|
||||
totalTime: s.totalTime,
|
||||
@@ -533,7 +576,7 @@ function getTimeRangeLabel(range: string) {
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
<ClientChart
|
||||
client:load
|
||||
client:visible
|
||||
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.client.name,
|
||||
totalTime: s.totalTime
|
||||
@@ -555,7 +598,7 @@ function getTimeRangeLabel(range: string) {
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
<MemberChart
|
||||
client:load
|
||||
client:visible
|
||||
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.member.name,
|
||||
totalTime: s.totalTime
|
||||
@@ -709,10 +752,18 @@ function getTimeRangeLabel(range: string) {
|
||||
{/* Detailed Entries */}
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:document-text" class="w-6 h-6" />
|
||||
Detailed Entries ({entries.length})
|
||||
</h2>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">
|
||||
<Icon name="heroicons:document-text" class="w-6 h-6" />
|
||||
Detailed Entries ({entries.length})
|
||||
</h2>
|
||||
{entries.length > 0 && (
|
||||
<a href={`/api/reports/export${url.search}`} class="btn btn-sm btn-outline" target="_blank">
|
||||
<Icon name="heroicons:arrow-down-tray" class="w-4 h-4" />
|
||||
Export CSV
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{entries.length > 0 ? (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../db';
|
||||
import { apiTokens } from '../../db/schema';
|
||||
import { apiTokens, passkeys } from '../../db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import ProfileForm from '../../components/settings/ProfileForm.vue';
|
||||
import PasswordForm from '../../components/settings/PasswordForm.vue';
|
||||
import ApiTokenManager from '../../components/settings/ApiTokenManager.vue';
|
||||
import PasskeyManager from '../../components/settings/PasskeyManager.vue';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
@@ -16,6 +20,12 @@ const userTokens = await db.select()
|
||||
.where(eq(apiTokens.userId, user.id))
|
||||
.orderBy(desc(apiTokens.createdAt))
|
||||
.all();
|
||||
|
||||
const userPasskeys = await db.select()
|
||||
.from(passkeys)
|
||||
.where(eq(passkeys.userId, user.id))
|
||||
.orderBy(desc(passkeys.createdAt))
|
||||
.all();
|
||||
---
|
||||
|
||||
<DashboardLayout title="Account Settings - Chronus">
|
||||
@@ -40,177 +50,25 @@ const userTokens = await db.select()
|
||||
)}
|
||||
|
||||
<!-- Profile Information -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||
<Icon name="heroicons:user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
Profile Information
|
||||
</h2>
|
||||
|
||||
<form action="/api/user/update-profile" method="POST" class="space-y-5">
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">Full Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={user.name}
|
||||
placeholder="Your full name"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">Email</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={user.email}
|
||||
placeholder="your@email.com"
|
||||
class="input input-bordered w-full"
|
||||
disabled
|
||||
/>
|
||||
<div class="label pt-2">
|
||||
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="submit" class="btn btn-primary w-full sm:w-auto">
|
||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileForm client:load user={user} />
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||
<Icon name="heroicons:key" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
Change Password
|
||||
</h2>
|
||||
<PasswordForm client:load />
|
||||
|
||||
<form action="/api/user/change-password" method="POST" class="space-y-5">
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="currentPassword"
|
||||
placeholder="Enter current password"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">New Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="newPassword"
|
||||
placeholder="Enter new password"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
<div class="label pt-2">
|
||||
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Minimum 8 characters</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">Confirm New Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm new password"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="submit" class="btn btn-primary w-full sm:w-auto">
|
||||
<Icon name="heroicons:lock-closed" class="w-5 h-5" />
|
||||
Update Password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Passkeys -->
|
||||
<PasskeyManager client:idle initialPasskeys={userPasskeys.map(pk => ({
|
||||
...pk,
|
||||
lastUsedAt: pk.lastUsedAt ? pk.lastUsedAt.toISOString() : null,
|
||||
createdAt: pk.createdAt ? pk.createdAt.toISOString() : null
|
||||
}))} />
|
||||
|
||||
<!-- API Tokens -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="card-title text-lg sm:text-xl">
|
||||
<Icon name="heroicons:code-bracket-square" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
API Tokens
|
||||
</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="createTokenModal.showModal()">
|
||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||
Create Token
|
||||
</button>
|
||||
</div>
|
||||
<ApiTokenManager client:idle initialTokens={userTokens.map(t => ({
|
||||
...t,
|
||||
lastUsedAt: t.lastUsedAt ? t.lastUsedAt.toISOString() : null,
|
||||
createdAt: t.createdAt ? t.createdAt.toISOString() : ''
|
||||
}))} />
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Last Used</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userTokens.length === 0 ? (
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-base-content/60 py-4">
|
||||
No API tokens found. Create one to access the API.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
userTokens.map(token => (
|
||||
<tr>
|
||||
<td class="font-medium">{token.name}</td>
|
||||
<td class="text-sm">
|
||||
{token.lastUsedAt ? token.lastUsedAt.toLocaleDateString() : 'Never'}
|
||||
</td>
|
||||
<td class="text-sm">
|
||||
{token.createdAt ? token.createdAt.toLocaleDateString() : 'N/A'}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick={`deleteToken('${token.id}')`}
|
||||
>
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Info -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||
@@ -238,132 +96,5 @@ const userTokens = await db.select()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Token Modal -->
|
||||
<dialog id="createTokenModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Create API Token</h3>
|
||||
<p class="py-4 text-sm text-base-content/70">
|
||||
API tokens allow you to authenticate with the API programmatically.
|
||||
Give your token a descriptive name.
|
||||
</p>
|
||||
|
||||
<form id="createTokenForm" method="dialog" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium">Token Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="tokenName"
|
||||
placeholder="e.g. CI/CD Pipeline"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="createTokenModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Generate Token</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Show Token Modal -->
|
||||
<dialog id="showTokenModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg text-success flex items-center gap-2">
|
||||
<Icon name="heroicons:check-circle" class="w-6 h-6" />
|
||||
Token Created
|
||||
</h3>
|
||||
<p class="py-4">
|
||||
Make sure to copy your personal access token now. You won't be able to see it again!
|
||||
</p>
|
||||
|
||||
<div class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group">
|
||||
<span id="newTokenDisplay"></span>
|
||||
<button
|
||||
class="absolute top-2 right-2 btn btn-xs btn-ghost opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick="copyToken()"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<Icon name="heroicons:clipboard" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-primary" onclick="closeShowTokenModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script is:inline>
|
||||
// Handle Token Creation
|
||||
const createTokenForm = document.getElementById('createTokenForm');
|
||||
|
||||
createTokenForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('tokenName').value;
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/tokens', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
document.getElementById('createTokenModal').close();
|
||||
document.getElementById('newTokenDisplay').innerText = data.token;
|
||||
document.getElementById('showTokenModal').showModal();
|
||||
document.getElementById('tokenName').value = ''; // Reset form
|
||||
} else {
|
||||
alert('Failed to create token');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating token:', error);
|
||||
alert('An error occurred');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Token Copy
|
||||
function copyToken() {
|
||||
const token = document.getElementById('newTokenDisplay').innerText;
|
||||
navigator.clipboard.writeText(token);
|
||||
}
|
||||
|
||||
// Handle Closing Show Token Modal (refresh page to show new token in list)
|
||||
function closeShowTokenModal() {
|
||||
document.getElementById('showTokenModal').close();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// Handle Token Deletion
|
||||
async function deleteToken(id) {
|
||||
if (!confirm('Are you sure you want to revoke this token? Any applications using it will stop working.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/user/tokens/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to delete token');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting token:', error);
|
||||
alert('An error occurred');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -207,7 +207,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
</div>
|
||||
) : (
|
||||
<ManualEntry
|
||||
client:load
|
||||
client:idle
|
||||
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
|
||||
categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))}
|
||||
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import PasskeyLogin from '../components/auth/PasskeyLogin.vue';
|
||||
|
||||
if (Astro.locals.user) {
|
||||
return Astro.redirect('/dashboard');
|
||||
@@ -60,6 +61,8 @@ const errorMessage =
|
||||
<button class="btn btn-primary w-full mt-6">Sign In</button>
|
||||
</form>
|
||||
|
||||
<PasskeyLogin client:idle />
|
||||
|
||||
<div class="divider">OR</div>
|
||||
|
||||
<div class="text-center">
|
||||
|
||||
@@ -24,7 +24,6 @@ export async function recalculateInvoiceTotals(invoiceId: string) {
|
||||
.all();
|
||||
|
||||
// Calculate totals
|
||||
// Note: amounts are in cents
|
||||
const subtotal = items.reduce((acc, item) => acc + item.amount, 0);
|
||||
|
||||
// Calculate discount
|
||||
|
||||
Reference in New Issue
Block a user