Add SaaS frontend code (#5879)

# Description of Changes
Adds the code for the SaaS frontend as proprietary code to the OSS repo.
This version of the code is adapted from 22/1/2026, which was the last
SaaS version based on the 'V2' design. This will move us closer to being
able to have the OSS products understand whether the user has a SaaS
account, and provide the correct UI in those cases.
This commit is contained in:
James Brunton
2026-03-11 11:53:54 +00:00
committed by GitHub
parent 8bc37bf5ae
commit fa8c52b2be
114 changed files with 12408 additions and 99 deletions
+2
View File
@@ -10,6 +10,8 @@ if that directory exists, is licensed under the license defined in "app/propriet
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE". if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
* All content that resides under the "frontend/src/desktop/" directory of this repository, * All content that resides under the "frontend/src/desktop/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE". if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
* All content that resides under the "frontend/src/saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/saas/LICENSE".
* Content outside of the above mentioned directories or restrictions above is * Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below. available under the MIT License as defined below.
+568 -1
View File
@@ -57,8 +57,10 @@
"@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-shell": "^2.3.5",
"@userback/widget": "^0.3.12",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"axios": "^1.13.2", "axios": "^1.13.2",
"d3": "^7.9.0",
"globals": "^17.1.0", "globals": "^17.1.0",
"i18next": "^25.5.2", "i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
@@ -70,6 +72,7 @@
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-easy-crop": "^5.5.6",
"react-i18next": "^15.7.3", "react-i18next": "^15.7.3",
"react-rnd": "^10.5.2", "react-rnd": "^10.5.2",
"react-router-dom": "^7.9.1", "react-router-dom": "^7.9.1",
@@ -89,6 +92,7 @@
"@testing-library/jest-dom": "^6.8.0", "@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/d3": "^7.4.3",
"@types/gapi": "^0.0.47", "@types/gapi": "^0.0.47",
"@types/gapi.client.drive-v3": "^0.0.5", "@types/gapi.client.drive-v3": "^0.0.5",
"@types/google.accounts": "^0.0.18", "@types/google.accounts": "^0.0.18",
@@ -4561,24 +4565,173 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": { "node_modules/@types/d3-array": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-color": { "node_modules/@types/d3-color": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-ease": { "node_modules/@types/d3-ease": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-interpolate": { "node_modules/@types/d3-interpolate": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -4594,6 +4747,27 @@
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-scale": { "node_modules/@types/d3-scale": {
"version": "4.0.9", "version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
@@ -4603,6 +4777,20 @@
"@types/d3-time": "*" "@types/d3-time": "*"
} }
}, },
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-shape": { "node_modules/@types/d3-shape": {
"version": "3.1.8", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@@ -4618,12 +4806,40 @@
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-timer": { "node_modules/@types/d3-timer": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -4678,6 +4894,13 @@
"@maxim_mazurok/gapi.client.drive-v3": "latest" "@maxim_mazurok/gapi.client.drive-v3": "latest"
} }
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/google.accounts": { "node_modules/@types/google.accounts": {
"version": "0.0.18", "version": "0.0.18",
"resolved": "https://registry.npmjs.org/@types/google.accounts/-/google.accounts-0.0.18.tgz", "resolved": "https://registry.npmjs.org/@types/google.accounts/-/google.accounts-0.0.18.tgz",
@@ -5025,6 +5248,12 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@userback/widget": {
"version": "0.3.12",
"resolved": "https://registry.npmjs.org/@userback/widget/-/widget-0.3.12.tgz",
"integrity": "sha512-68YihQblx1+4kFpd7e4nDBSV6ljSY0+hzIHHgbxgbJPJIhNgN4lyvOjcDbCrEb9qh6yXx5W6HBLKTMJD4hLXGg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react-swc": { "node_modules/@vitejs/plugin-react-swc": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.3.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.3.tgz",
@@ -6300,7 +6529,6 @@
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
@@ -6464,6 +6692,47 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": { "node_modules/d3-array": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@@ -6476,6 +6745,43 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": { "node_modules/d3-color": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@@ -6485,6 +6791,77 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": { "node_modules/d3-ease": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@@ -6494,6 +6871,32 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": { "node_modules/d3-format": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
@@ -6503,6 +6906,27 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": { "node_modules/d3-interpolate": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
@@ -6524,6 +6948,33 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": { "node_modules/d3-scale": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
@@ -6540,6 +6991,28 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": { "node_modules/d3-shape": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@@ -6585,6 +7058,41 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/data-uri-to-buffer": { "node_modules/data-uri-to-buffer": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
@@ -6736,6 +7244,15 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -8351,6 +8868,18 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -9812,6 +10341,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/npm-normalize-package-bin": { "node_modules/npm-normalize-package-bin": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz",
@@ -11052,6 +11587,20 @@
"react": ">= 16.8 || 18.0.0" "react": ">= 16.8 || 18.0.0"
} }
}, },
"node_modules/react-easy-crop": {
"version": "5.5.6",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.6.tgz",
"integrity": "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1",
"tslib": "^2.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "15.7.4", "version": "15.7.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
@@ -11632,6 +12181,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.59.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -11677,12 +12232,24 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sass-lookup": { "node_modules/sass-lookup": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/sass-lookup/-/sass-lookup-6.1.0.tgz", "resolved": "https://registry.npmjs.org/sass-lookup/-/sass-lookup-6.1.0.tgz",
+17 -4
View File
@@ -52,8 +52,10 @@
"@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-shell": "^2.3.5",
"@userback/widget": "^0.3.12",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"axios": "^1.13.2", "axios": "^1.13.2",
"d3": "^7.9.0",
"globals": "^17.1.0", "globals": "^17.1.0",
"i18next": "^25.5.2", "i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
@@ -66,6 +68,7 @@
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-easy-crop": "^5.5.6",
"react-i18next": "^15.7.3", "react-i18next": "^15.7.3",
"react-rnd": "^10.5.2", "react-rnd": "^10.5.2",
"react-router-dom": "^7.9.1", "react-router-dom": "^7.9.1",
@@ -79,11 +82,19 @@
"pretauri-build": "node scripts/build-provisioner.mjs", "pretauri-build": "node scripts/build-provisioner.mjs",
"predev": "npm run generate-icons", "predev": "npm run generate-icons",
"dev": "vite", "dev": "vite",
"dev:core": "vite --mode core",
"dev:proprietary": "vite --mode proprietary",
"dev:saas": "vite --mode saas",
"dev:desktop": "vite --mode desktop",
"prebuild": "npm run generate-icons", "prebuild": "npm run generate-icons",
"lint": "npm run lint:eslint && npm run lint:cycles", "lint": "npm run lint:eslint && npm run lint:cycles",
"lint:eslint": "eslint --max-warnings=0", "lint:eslint": "eslint --max-warnings=0",
"lint:cycles": "dpdm src --circular --no-warning --no-tree --exit-code circular:1", "lint:cycles": "dpdm src --circular --no-warning --no-tree --exit-code circular:1",
"build": "vite build", "build": "vite build",
"build:core": "vite build --mode core",
"build:proprietary": "vite build --mode proprietary",
"build:saas": "vite build --mode saas",
"build:desktop": "vite build --mode desktop",
"preview": "vite preview", "preview": "vite preview",
"tauri-dev": "tauri dev --no-watch", "tauri-dev": "tauri dev --no-watch",
"tauri-build": "tauri build", "tauri-build": "tauri build",
@@ -93,10 +104,11 @@
"tauri-build-dev-linux": "tauri build --bundles appimage", "tauri-build-dev-linux": "tauri build --bundles appimage",
"tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build", "tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build",
"typecheck": "npm run typecheck:proprietary", "typecheck": "npm run typecheck:proprietary",
"typecheck:core": "tsc --noEmit --project tsconfig.core.json", "typecheck:core": "tsc --noEmit --project src/core/tsconfig.json",
"typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json", "typecheck:proprietary": "tsc --noEmit --project src/proprietary/tsconfig.json",
"typecheck:desktop": "tsc --noEmit --project tsconfig.desktop.json", "typecheck:saas": "tsc --noEmit --project src/saas/tsconfig.json",
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:desktop", "typecheck:desktop": "tsc --noEmit --project src/desktop/tsconfig.json",
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:saas && npm run typecheck:desktop",
"check": "npm run typecheck && npm run lint && npm run test:run", "check": "npm run typecheck && npm run lint && npm run test:run",
"generate-licenses": "node scripts/generate-licenses.js", "generate-licenses": "node scripts/generate-licenses.js",
"generate-icons": "node scripts/generate-icons.js", "generate-icons": "node scripts/generate-icons.js",
@@ -136,6 +148,7 @@
"@testing-library/jest-dom": "^6.8.0", "@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/d3": "^7.4.3",
"@types/gapi": "^0.0.47", "@types/gapi": "^0.0.47",
"@types/gapi.client.drive-v3": "^0.0.5", "@types/gapi.client.drive-v3": "^0.0.5",
"@types/google.accounts": "^0.0.18", "@types/google.accounts": "^0.0.18",
@@ -142,6 +142,10 @@ welcome = "Welcome"
white = "White" white = "White"
WorkInProgess = "Work in progress, May not work or be buggy, Please report any problems!" WorkInProgess = "Work in progress, May not work or be buggy, Please report any problems!"
yes = "Yes" yes = "Yes"
insufficientCredits = "Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}"
loadingCredits = "Checking credits..."
loadingProStatus = "Checking subscription status..."
noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan"
[account] [account]
accountSettings = "Account Settings" accountSettings = "Account Settings"
@@ -2563,10 +2567,45 @@ title = "Quality Adjustment"
tags = "squish,small,tiny" tags = "squish,small,tiny"
[config.account.overview] [config.account.overview]
confirmDelete = "Delete My Account"
deleteAccount = "Delete Account"
deleteAccountTitle = "Delete Account"
deleteFailed = "Failed to delete account."
deleteFailedTitle = "Unable to delete account"
deleteWarning = "This action is permanent and cannot be undone. All your data will be deleted."
enterEmailConfirm = "To confirm deletion, please type your email address ({{email}}) below:"
guestDescription = "You are signed in as a guest. Consider upgrading your account above." guestDescription = "You are signed in as a guest. Consider upgrading your account above."
label = "Overview"
manageAccountPreferences = "Manage your account preferences" manageAccountPreferences = "Manage your account preferences"
signedInAs = "Signed in as"
title = "Account Settings" title = "Account Settings"
[config.account.profilePicture]
description = "Upload an image to personalize your account."
help = "PNG, JPG, or WebP up to 2MB."
remove = "Remove"
sizeError = "Please select an image smaller than 2MB."
switchedToCustom = "Switched to custom picture. You can now upload your own."
title = "Profile picture"
upload = "Upload"
useCustom = "Use custom picture"
usingProvider = "Using {{provider}} profile picture"
[config.account.profilePicture.cropper]
cropError = "Failed to crop image. Please try again."
invalidImage = "Invalid image file. Please select a valid PNG, JPG, or WebP file."
processing = "Processing crop..."
save = "Save Cropped Image"
sizeErrorAfterCrop = "Cropped image is too large. Please zoom out or crop a smaller area."
title = "Crop Profile Picture"
zoom = "Zoom"
[config.account.security]
changePassword = "Change password"
description = "Manage your password and security settings."
title = "Passwords & Security"
update = "Update password"
[config.account.upgrade] [config.account.upgrade]
description = "Link your account to preserve your history and access more features!" description = "Link your account to preserve your history and access more features!"
email = "Email" email = "Email"
@@ -2580,9 +2619,13 @@ socialLogin = "Upgrade with Social Account"
title = "Upgrade Guest Account" title = "Upgrade Guest Account"
upgradeButton = "Upgrade Account" upgradeButton = "Upgrade Account"
[config]
plan = "Plan"
[config.apiKeys] [config.apiKeys]
chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}" chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}"
copyKeyAriaLabel = "Copy API key" copyKeyAriaLabel = "Copy API key"
creditsRemaining = "Credits Remaining"
description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one." description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one."
docsDescription = "Learn more about integrating with Stirling PDF:" docsDescription = "Learn more about integrating with Stirling PDF:"
docsLink = "API Documentation" docsLink = "API Documentation"
@@ -3842,6 +3885,7 @@ version = "Version"
accountCreatedSuccess = "Account created successfully! You can now sign in." accountCreatedSuccess = "Account created successfully! You can now sign in."
alreadyLoggedIn = "You are already logged in to" alreadyLoggedIn = "You are already logged in to"
alreadyLoggedIn2 = "devices. Please log out of the devices and try again." alreadyLoggedIn2 = "devices. Please log out of the devices and try again."
backToSignIn = "Back to sign in"
cancel = "Cancel" cancel = "Cancel"
changePasswordWarning = "Please change your password after logging in for the first time" changePasswordWarning = "Please change your password after logging in for the first time"
credentialsUpdated = "Your credentials have been updated. Please sign in again." credentialsUpdated = "Your credentials have been updated. Please sign in again."
@@ -3883,16 +3927,21 @@ or = "Or"
password = "Password" password = "Password"
passwordChangedSuccess = "Password changed successfully! Please sign in with your new password." passwordChangedSuccess = "Password changed successfully! Please sign in with your new password."
passwordResetSent = "Password reset link sent to {{email}}! Check your email and follow the instructions." passwordResetSent = "Password reset link sent to {{email}}! Check your email and follow the instructions."
passwordUpdatedSuccess = "Your password has been updated successfully."
pleaseEnterBoth = "Please enter both email and password" pleaseEnterBoth = "Please enter both email and password"
pleaseEnterEmail = "Please enter your email address" pleaseEnterEmail = "Please enter your email address"
relyingPartyRegistrationNotFound = "No relying party registration found" relyingPartyRegistrationNotFound = "No relying party registration found"
rememberme = "Remember me" rememberme = "Remember me"
resetHelp = "Enter your email to receive a secure link to reset your password. If the link has expired, please request a new one."
resetYourPassword = "Reset your password"
saml2RequiresLicense = "SAML login requires an Enterprise license. Please contact the administrator to upgrade your plan." saml2RequiresLicense = "SAML login requires an Enterprise license. Please contact the administrator to upgrade your plan."
sending = "Sending…" sending = "Sending…"
sendMagicLink = "Send Magic Link" sendMagicLink = "Send Magic Link"
sendResetLink = "Send reset link"
sessionExpired = "Your session has expired. Please sign in again." sessionExpired = "Your session has expired. Please sign in again."
signin = "Sign in" signin = "Sign in"
signInAnonymously = "Sign Up as a Guest" signInAnonymously = "Sign Up as a Guest"
subtitle = "Sign back in to Stirling PDF"
signingIn = "Signing in..." signingIn = "Signing in..."
signinTitle = "Please sign in" signinTitle = "Please sign in"
signInWith = "Sign in with" signInWith = "Sign in with"
@@ -3901,6 +3950,7 @@ ssoSignIn = "Login via Single Sign-on"
title = "Sign in" title = "Sign in"
toManySessions = "You have too many active sessions" toManySessions = "You have too many active sessions"
unexpectedError = "Unexpected error: {{message}}" unexpectedError = "Unexpected error: {{message}}"
updatePassword = "Update password"
useEmailInstead = "Login with email" useEmailInstead = "Login with email"
useMagicLink = "Use magic link instead" useMagicLink = "Use magic link instead"
userIsDisabled = "User is deactivated, login is currently blocked with this username. Please contact the administrator." userIsDisabled = "User is deactivated, login is currently blocked with this username. Please contact the administrator."
@@ -4210,6 +4260,15 @@ viewSwitcher = "Use these controls to select how you want to view your PDFs."
workbench = "This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs." workbench = "This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs."
wrapUp = "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again." wrapUp = "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."
[onboarding.freeTrial]
afterTrialWithoutPayment = "After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access."
afterTrialWithPayment = "Your Pro subscription will start automatically when the trial ends."
body = "You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing."
daysRemaining = "{{days}} days remaining"
daysRemainingSingular = "{{days}} day remaining"
title = "Your 30-Day Pro Trial"
trialEnds = "Trial ends {{date}}"
[onboarding.buttons] [onboarding.buttons]
back = "Back" back = "Back"
download = "Download →" download = "Download →"
@@ -4947,7 +5006,10 @@ perMonth = "/month"
perSeat = "/seat" perSeat = "/seat"
popular = "Popular" popular = "Popular"
selectPlan = "Select Plan" selectPlan = "Select Plan"
selectCredits = "Select Credit Amount"
showComparison = "Compare All Features" showComparison = "Compare All Features"
purchase = "Purchase"
totalCost = "Total Cost"
upgrade = "Upgrade" upgrade = "Upgrade"
withServer = "+ Server Plan" withServer = "+ Server Plan"
@@ -5039,6 +5101,30 @@ successMessage = "Your license has been successfully activated. You can now clos
name = "Team" name = "Team"
siteLicense = "Site License" siteLicense = "Site License"
[plan.api]
large = "5,000 Credits"
medium = "1,000 Credits"
small = "500 Credits"
xsmall = "100 Credits"
[plan.apiPackages]
subtitle = "Purchase API credits for your applications"
title = "API Credit Packages"
[plan.trial]
badge = "Trial"
continueWithFree = "Continue with Free"
daysRemaining = "Your trial ends in {{days}} days"
endDate = "Expires: {{date}}"
expired = "Your Trial Has Ended"
expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier."
freeTierLimitations = "Free tier includes basic PDF tools with usage limits."
message = ""
subscribe = "Subscribe to Pro"
subscribeToPro = "Subscribe to Pro"
subscriptionScheduled = "Subscription scheduled - starts {{date}}"
title = "Free Trial Active"
[credits] [credits]
enableOverageBilling = "Enable Overage Billing" enableOverageBilling = "Enable Overage Billing"
maybeLater = "Maybe later" maybeLater = "Maybe later"
@@ -0,0 +1,7 @@
/**
* Core stub for HomePage extensions.
*/
export function HomePageExtensions() {
return null;
}
@@ -28,6 +28,7 @@ import {
} from '@app/components/shared/quickAccessBar/QuickAccessBar'; } from '@app/components/shared/quickAccessBar/QuickAccessBar';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import { QuickAccessBarFooterExtensions } from '@app/components/quickAccessBar/QuickAccessBarFooterExtensions'; import { QuickAccessBarFooterExtensions } from '@app/components/quickAccessBar/QuickAccessBarFooterExtensions';
import { useConfigButtonIcon } from '@app/hooks/useConfigButtonIcon';
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => { const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -44,6 +45,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const [configModalOpen, setConfigModalOpen] = useState(false); const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools'); const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null); const scrollableRef = useRef<HTMLDivElement>(null);
const configButtonIcon = useConfigButtonIcon();
const { const {
tooltipOpen, tooltipOpen,
manualCloseOnly, manualCloseOnly,
@@ -202,7 +205,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
...(shouldHideSettingsButton ? [] : [{ ...(shouldHideSettingsButton ? [] : [{
id: 'config', id: 'config',
name: t("quickAccess.settings", "Settings"), name: t("quickAccess.settings", "Settings"),
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />, icon: configButtonIcon ?? <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'md' as const, size: 'md' as const,
type: 'modal' as const, type: 'modal' as const,
onClick: () => { onClick: () => {
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon'; import LocalIcon from '@app/components/shared/LocalIcon';
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types'; import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/config/configNavSections'; import type { ConfigNavSection, ConfigNavItem } from '@app/components/shared/config/configNavSections';
interface SettingsSearchBarProps { interface SettingsSearchBarProps {
configNavSections: ConfigNavSection[]; configNavSections: ConfigNavSection[];
@@ -30,9 +30,11 @@ const BANNER_DISMISSED_KEY = "stirlingpdf_features_banner_dismissed";
interface GeneralSectionProps { interface GeneralSectionProps {
hideTitle?: boolean; hideTitle?: boolean;
hideUpdateSection?: boolean;
hideAdminBanner?: boolean;
} }
const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) => { const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false, hideUpdateSection = false, hideAdminBanner = false }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences(); const { preferences, updatePreference } = usePreferences();
const { config } = useAppConfig(); const { config } = useAppConfig();
@@ -156,7 +158,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
</div> </div>
)} )}
{loginDisabled && !bannerDismissed && ( {!hideAdminBanner && loginDisabled && !bannerDismissed && (
<Paper withBorder p="md" radius="md" style={{ background: "var(--mantine-color-blue-0)", position: "relative" }}> <Paper withBorder p="md" radius="md" style={{ background: "var(--mantine-color-blue-0)", position: "relative" }}>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
@@ -215,7 +217,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
)} )}
{/* Update Check Section */} {/* Update Check Section */}
{config?.appVersion && ( {!hideUpdateSection && config?.appVersion && (
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
<Stack gap="md"> <Stack gap="md">
<div> <div>
+11 -25
View File
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode, useCa
import apiClient from '@app/services/apiClient'; import apiClient from '@app/services/apiClient';
import { getSimulatedAppConfig } from '@app/testing/serverExperienceSimulations'; import { getSimulatedAppConfig } from '@app/testing/serverExperienceSimulations';
import type { AppConfig, AppConfigBootstrapMode } from '@app/types/appConfig'; import type { AppConfig, AppConfigBootstrapMode } from '@app/types/appConfig';
import { useJwtConfigSync } from '@app/hooks/useJwtConfigSync';
/** /**
* Sleep utility for delays * Sleep utility for delays
@@ -41,6 +42,7 @@ export interface AppConfigProviderProps {
initialConfig?: AppConfig | null; initialConfig?: AppConfig | null;
bootstrapMode?: AppConfigBootstrapMode; bootstrapMode?: AppConfigBootstrapMode;
autoFetch?: boolean; autoFetch?: boolean;
onConfigLoaded?: (config: AppConfig) => void;
} }
export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
@@ -49,6 +51,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
initialConfig = null, initialConfig = null,
bootstrapMode = 'blocking', bootstrapMode = 'blocking',
autoFetch = true, autoFetch = true,
onConfigLoaded,
}) => { }) => {
const isBlockingMode = bootstrapMode === 'blocking'; const isBlockingMode = bootstrapMode === 'blocking';
const [config, setConfig] = useState<AppConfig | null>(initialConfig); const [config, setConfig] = useState<AppConfig | null>(initialConfig);
@@ -58,6 +61,9 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
const [hasResolvedConfig, setHasResolvedConfig] = useState(Boolean(initialConfig) && !isBlockingMode); const [hasResolvedConfig, setHasResolvedConfig] = useState(Boolean(initialConfig) && !isBlockingMode);
const [loading, setLoading] = useState(!hasResolvedConfig); const [loading, setLoading] = useState(!hasResolvedConfig);
const onConfigLoadedRef = React.useRef(onConfigLoaded);
onConfigLoadedRef.current = onConfigLoaded;
const maxRetries = retryOptions?.maxRetries ?? 0; const maxRetries = retryOptions?.maxRetries ?? 0;
const initialDelay = retryOptions?.initialDelay ?? 1000; const initialDelay = retryOptions?.initialDelay ?? 1000;
@@ -113,6 +119,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
setConfig(data); setConfig(data);
setHasResolvedConfig(true); setHasResolvedConfig(true);
setLoading(false); setLoading(false);
onConfigLoadedRef.current?.(data);
return; // Success - exit function return; // Success - exit function
} catch (err: any) { } catch (err: any) {
const status = err?.response?.status; const status = err?.response?.status;
@@ -151,42 +158,21 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
setLoading(false); setLoading(false);
}, [hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]); }, [hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]);
useEffect(() => { const { isAuthPage } = useJwtConfigSync(fetchConfig);
// Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*)
// Config will be fetched after successful authentication via jwt-available event
const currentPath = window.location.pathname;
const isAuthPage = currentPath.includes('/login') ||
currentPath.includes('/signup') ||
currentPath.includes('/auth/callback') ||
currentPath.includes('/invite/');
// On auth pages, always skip the config fetch useEffect(() => {
// The config will be fetched after authentication via jwt-available event
if (isAuthPage) { if (isAuthPage) {
console.debug('[AppConfig] On auth page - using default config, skipping fetch', { path: currentPath }); console.debug('[AppConfig] On auth page - using default config, skipping fetch', { path: window.location.pathname });
setConfig({ enableLogin: true }); setConfig({ enableLogin: true });
setHasResolvedConfig(true); setHasResolvedConfig(true);
setLoading(false); setLoading(false);
return; return;
} }
// On non-auth pages, fetch config (will validate JWT if present)
if (autoFetch) { if (autoFetch) {
fetchConfig(); fetchConfig();
} }
}, [autoFetch, fetchConfig]); }, [autoFetch, fetchConfig, isAuthPage]);
// Listen for JWT availability (triggered on login/signup)
useEffect(() => {
const handleJwtAvailable = () => {
console.debug('[AppConfig] JWT available event - refetching config');
// Force refetch with JWT
fetchConfig(true);
};
window.addEventListener('jwt-available', handleJwtAvailable);
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
}, [fetchConfig]);
const refetch = useCallback(() => fetchConfig(true), [fetchConfig]); const refetch = useCallback(() => fetchConfig(true), [fetchConfig]);
@@ -121,13 +121,11 @@ export const useToolOperation = <TParams>(
? config.endpoint(params) ? config.endpoint(params)
: config.endpoint; : config.endpoint;
// Credit check for cloud operations (desktop SaaS mode only, no-op in web builds) // Credit check — no-op in core builds, real check in desktop/SaaS versions
if (willUseCloud && endpoint) { const creditError = await checkCredits();
const creditError = await checkCredits(); if (creditError !== null) {
if (creditError !== null) { actions.setError(creditError);
actions.setError(creditError); return;
return;
}
} }
// Backend readiness check (will skip for SaaS-routed endpoints) // Backend readiness check (will skip for SaaS-routed endpoints)
@@ -0,0 +1,6 @@
/**
* Core stub — config button uses the default settings icon.
*/
export function useConfigButtonIcon(): React.ReactNode {
return null;
}
@@ -0,0 +1,26 @@
import { useEffect } from 'react';
/**
* Core implementation: sets up the jwt-available event listener for OSS JWT auth,
* and detects auth pages where config fetch should be skipped.
*/
export function useJwtConfigSync(fetchConfig: (force?: boolean) => void): { isAuthPage: boolean } {
const currentPath = window.location.pathname;
const isAuthPage =
currentPath.includes('/login') ||
currentPath.includes('/signup') ||
currentPath.includes('/auth/callback') ||
currentPath.includes('/invite/');
useEffect(() => {
const handleJwtAvailable = () => {
console.debug('[AppConfig] JWT available event - refetching config');
fetchConfig(true);
};
window.addEventListener('jwt-available', handleJwtAvailable);
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
}, [fetchConfig]);
return { isAuthPage };
}
+2
View File
@@ -23,6 +23,7 @@ import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import AppConfigModal from "@app/components/shared/AppConfigModal"; import AppConfigModal from "@app/components/shared/AppConfigModal";
import { getStartupNavigationAction } from "@app/utils/homePageNavigation"; import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
import { HomePageExtensions } from "@app/components/home/HomePageExtensions";
import "@app/pages/HomePage.css"; import "@app/pages/HomePage.css";
@@ -204,6 +205,7 @@ export default function HomePage() {
return ( return (
<div className="h-screen overflow-hidden"> <div className="h-screen overflow-hidden">
<HomePageExtensions />
{isMobile ? ( {isMobile ? (
<div className="mobile-layout"> <div className="mobile-layout">
<div className="mobile-toggle"> <div className="mobile-toggle">
+10
View File
@@ -13,3 +13,13 @@
@layer utilities { @layer utilities {
@tailwind utilities; @tailwind utilities;
} }
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "../../",
"paths": {
"@app/*": [
"src/core/*"
]
}
},
"include": [
"../global.d.ts",
"../*.js",
"../*.ts",
"../*.tsx",
"."
]
}
+51
View File
@@ -0,0 +1,51 @@
Stirling PDF User License
Copyright (c) 2025 Stirling PDF Inc.
License Scope & Usage Rights
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
covering the appropriate number of licensed users.
Trial and Minimal Use
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
* Use is limited to the capabilities and restrictions defined by the Software itself;
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
Continued use beyond this scope requires a valid Stirling PDF User License.
Modifications and Derivative Works
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
* May not be deployed in production environments without a valid User License;
* May not be distributed or sublicensed;
* Remain the intellectual property of Stirling PDF and/or its licensors;
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
Prohibited Actions
Unless explicitly permitted by a paid license or separate agreement, you may not:
* Use the Software in production environments;
* Copy, merge, distribute, sublicense, or sell the Software;
* Remove or alter any licensing or copyright notices;
* Circumvent access restrictions or licensing requirements.
Third-Party Components
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
their original license terms as provided by their respective owners.
Disclaimer
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -8,6 +8,8 @@ import { SaaSTeamsSection } from '@app/components/shared/config/configSections/S
import { connectionModeService } from '@app/services/connectionModeService'; import { connectionModeService } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService'; import { authService } from '@app/services/authService';
export type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/config/configNavSections';
/** /**
* Hook version of desktop config nav sections with proper i18n support * Hook version of desktop config nav sections with proper i18n support
*/ */
@@ -1,6 +1,7 @@
{ {
"extends": "./tsconfig.proprietary.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": "../../",
"paths": { "paths": {
"@app/*": [ "@app/*": [
"src/desktop/*", "src/desktop/*",
@@ -16,11 +17,11 @@
} }
}, },
"include": [ "include": [
"src/global.d.ts", "../global.d.ts",
"src/*.js", "../*.js",
"src/*.ts", "../*.ts",
"src/*.tsx", "../*.tsx",
"src/core/setupTests.ts", "../core/setupTests.ts",
"src/desktop" "."
] ]
} }
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "../../",
"paths": {
"@app/*": [
"src/proprietary/*",
"src/core/*"
],
"@core/*": [
"src/core/*"
]
}
},
"include": [
"../global.d.ts",
"../*.js",
"../*.ts",
"../*.tsx",
"../core/setupTests.ts",
"."
]
}
+49
View File
@@ -0,0 +1,49 @@
import { Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import { AppProviders } from '@app/components/AppProviders';
import { setBaseUrl } from '@app/constants/app';
import type { AppConfig } from '@app/contexts/AppConfigContext';
import { AppLayout } from '@app/components/AppLayout';
import { LoadingFallback } from '@app/components/shared/LoadingFallback';
import OnboardingTour from '@app/components/onboarding/OnboardingTour';
import Landing from '@app/routes/Landing';
import Login from '@app/routes/Login';
import Signup from '@app/routes/Signup';
import AuthCallback from '@app/routes/AuthCallback';
import ResetPassword from '@app/routes/ResetPassword';
import OnboardingBootstrap from '@app/components/OnboardingBootstrap';
import TrialExpiredBootstrap from '@app/components/TrialExpiredBootstrap';
// Import global styles
import '@app/styles/tailwind.css';
import '@app/styles/saas-theme.css';
import '@app/styles/cookieconsent.css';
import '@app/styles/index.css';
// Import file ID debugging helpers (development only)
import '@app/utils/fileIdSafety';
function handleConfigLoaded(config: AppConfig) {
if (config.baseUrl) setBaseUrl(config.baseUrl);
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}>
<AppLayout>
<OnboardingBootstrap />
<TrialExpiredBootstrap />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/auth/reset" element={<ResetPassword />} />
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
</AppLayout>
</AppProviders>
</Suspense>
);
}
+51
View File
@@ -0,0 +1,51 @@
Stirling PDF User License
Copyright (c) 2025 Stirling PDF Inc.
License Scope & Usage Rights
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
covering the appropriate number of licensed users.
Trial and Minimal Use
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
* Use is limited to the capabilities and restrictions defined by the Software itself;
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
Continued use beyond this scope requires a valid Stirling PDF User License.
Modifications and Derivative Works
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
* May not be deployed in production environments without a valid User License;
* May not be distributed or sublicensed;
* Remain the intellectual property of Stirling PDF and/or its licensors;
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
Prohibited Actions
Unless explicitly permitted by a paid license or separate agreement, you may not:
* Use the Software in production environments;
* Copy, merge, distribute, sublicense, or sell the Software;
* Remove or alter any licensing or copyright notices;
* Circumvent access restrictions or licensing requirements.
Third-Party Components
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
their original license terms as provided by their respective owners.
Disclaimer
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+583
View File
@@ -0,0 +1,583 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'
import { supabase } from '@app/auth/supabase'
import type { Session, User as SupabaseUser, AuthError } from '@supabase/supabase-js'
import { CreditSummary, SubscriptionInfo, CreditCheckResult, ApiCredits } from '@app/types/credits'
import apiClient, { setGlobalCreditUpdateCallback } from '@app/services/apiClient'
import { synchronizeUserUpgrade } from '@app/services/userService'
import { syncOAuthAvatar, getProfilePictureMetadata, type ProfilePictureMetadata } from '@app/services/avatarSyncService'
// Extend Supabase User to include optional username for compatibility
export type User = SupabaseUser & { username?: string };
export interface TrialStatus {
isTrialing: boolean
trialEnd: string
daysRemaining: number
hasPaymentMethod: boolean
hasScheduledSub: boolean
status: string
}
interface AuthContextType {
session: Session | null
user: User | null
loading: boolean
error: AuthError | null
creditBalance: number | null
subscription: SubscriptionInfo | null
creditSummary: CreditSummary | null
isPro: boolean | null
trialStatus: TrialStatus | null
profilePictureUrl: string | null
profilePictureMetadata: ProfilePictureMetadata | null
signOut: () => Promise<void>
refreshSession: () => Promise<void>
hasSufficientCredits: (requiredCredits: number) => CreditCheckResult
updateCredits: (newBalance: number) => void
refreshCredits: () => Promise<void>
refreshProStatus: () => Promise<void>
refreshTrialStatus: () => Promise<void>
refreshProfilePicture: () => Promise<void>
refreshProfilePictureMetadata: () => Promise<void>
}
const AuthContext = createContext<AuthContextType>({
session: null,
user: null,
loading: true,
error: null,
creditBalance: null,
subscription: null,
creditSummary: null,
isPro: null,
trialStatus: null,
profilePictureUrl: null,
profilePictureMetadata: null,
signOut: async () => {},
refreshSession: async () => {},
hasSufficientCredits: () => ({ hasSufficientCredits: false, currentBalance: 0, requiredCredits: 0 }),
updateCredits: () => {},
refreshCredits: async () => {},
refreshProStatus: async () => {},
refreshTrialStatus: async () => {},
refreshProfilePicture: async () => {},
refreshProfilePictureMetadata: async () => {}
})
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<AuthError | null>(null)
const [creditBalance, setCreditBalance] = useState<number | null>(null)
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null)
const [creditSummary, setCreditSummary] = useState<CreditSummary | null>(null)
const [isPro, setIsPro] = useState<boolean | null>(null)
const [trialStatus, setTrialStatus] = useState<TrialStatus | null>(null)
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(null)
const [profilePictureMetadata, setProfilePictureMetadata] = useState<ProfilePictureMetadata | null>(null)
const fetchCredits = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
if (!currentSession?.user) {
console.debug('[Auth Debug] No user session, skipping credit fetch')
setCreditBalance(null)
setCreditSummary(null)
setSubscription(null)
return
}
try {
console.debug('[Auth Debug] Fetching credits for user:', currentSession.user.id)
const response = await apiClient.get<ApiCredits>('/api/v1/credits')
const apiCredits = response.data
// Map server payload to app CreditSummary
const credits: CreditSummary = {
currentCredits: apiCredits.totalAvailableCredits,
maxCredits: apiCredits.weeklyCreditsAllocated + apiCredits.totalBoughtCredits,
creditsUsed: (apiCredits.weeklyCreditsAllocated - apiCredits.weeklyCreditsRemaining) + (apiCredits.totalBoughtCredits - apiCredits.boughtCreditsRemaining),
creditsRemaining: apiCredits.totalAvailableCredits,
resetDate: apiCredits.weeklyResetDate,
weeklyAllowance: apiCredits.weeklyCreditsAllocated
}
setCreditSummary(credits)
setCreditBalance(credits.creditsRemaining)
const subscriptionInfo: SubscriptionInfo = {
status: 'active',
tier: (credits.weeklyAllowance || 0) > 100 ? 'premium' : 'free',
creditsPerWeek: credits.weeklyAllowance,
maxCredits: credits.maxCredits
}
setSubscription(subscriptionInfo)
console.debug('[Auth Debug] Credits fetched successfully:', credits)
} catch (error: any) {
console.debug('[Auth Debug] Failed to fetch credits:', error)
// Don't set error state for credit fetching failures to avoid disrupting auth flow
// Credits might not be available in all deployments
setCreditBalance(null)
setCreditSummary(null)
setSubscription(null)
}
}, [session])
const refreshCredits = useCallback(async () => {
await fetchCredits()
}, [fetchCredits])
const fetchProStatus = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
if (!currentSession?.user) {
console.debug('[Auth Debug] No user session, skipping pro status fetch')
setIsPro(null)
return
}
try {
console.debug('[Auth Debug] Fetching pro status for user:', currentSession.user.id)
const { data: proStatus, error } = await supabase.rpc('is_pro')
if (error) {
console.error('[Auth Debug] Error checking Pro status:', error)
setIsPro(false) // Default to false if there's an error
} else {
const isProUser = Boolean(proStatus)
setIsPro(isProUser)
console.debug('[Auth Debug] Pro status fetched:', isProUser)
}
} catch (error: any) {
console.debug('[Auth Debug] Failed to fetch pro status:', error)
setIsPro(false) // Default to false if there's an error
}
}, [session])
const refreshProStatus = useCallback(async () => {
await fetchProStatus()
}, [fetchProStatus])
const fetchTrialStatus = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
if (!currentSession?.user) {
console.debug('[Auth Debug] No user session, skipping trial status fetch')
setTrialStatus(null)
return
}
try {
console.debug('[Auth Debug] Fetching trial status for user:', currentSession.user.id)
const { data, error } = await supabase
.from('billing_subscriptions')
.select('status, trial_end, has_payment_method, scheduled_subscription_id')
.in('status', ['trialing', 'incomplete_expired', 'canceled'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (error) {
console.error('[Auth Debug] Error fetching trial status:', error)
setTrialStatus(null)
return
}
if (data?.trial_end) {
const trialEnd = new Date(data.trial_end)
const now = new Date()
const daysRemaining = Math.ceil((trialEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
setTrialStatus({
isTrialing: data.status === 'trialing' && daysRemaining > 0,
trialEnd: data.trial_end,
daysRemaining: Math.max(0, daysRemaining),
hasPaymentMethod: data.has_payment_method || false,
hasScheduledSub: !!data.scheduled_subscription_id,
status: data.status
})
console.debug('[Auth Debug] Trial status fetched:', {
status: data.status,
daysRemaining: Math.max(0, daysRemaining),
hasPaymentMethod: data.has_payment_method,
isTrialing: data.status === 'trialing' && daysRemaining > 0
})
} else {
setTrialStatus(null)
}
} catch (error: any) {
console.debug('[Auth Debug] Failed to fetch trial status:', error)
setTrialStatus(null)
}
}, [session])
const refreshTrialStatus = useCallback(async () => {
await fetchTrialStatus()
}, [fetchTrialStatus])
const fetchProfilePicture = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
if (!currentSession?.user) {
console.debug('[Auth Debug] No user session, skipping profile picture fetch')
setProfilePictureUrl(null)
return
}
try {
const PROFILE_BUCKET = 'profile-pictures'
const profilePath = `${currentSession.user.id}/avatar`
console.debug('[Auth Debug] Fetching profile picture for user:', currentSession.user.id)
const { data, error } = await supabase
.storage
.from(PROFILE_BUCKET)
.createSignedUrl(profilePath, 60 * 60)
if (error) {
// Profile picture not found is expected for users without uploads
console.debug('[Auth Debug] Profile picture not available:', error.message)
setProfilePictureUrl(null)
} else {
setProfilePictureUrl(data.signedUrl)
console.debug('[Auth Debug] Profile picture URL fetched successfully')
}
} catch (error: any) {
console.debug('[Auth Debug] Failed to fetch profile picture:', error)
setProfilePictureUrl(null)
}
}, [session])
const refreshProfilePicture = useCallback(async () => {
await fetchProfilePicture()
}, [fetchProfilePicture])
const fetchProfilePictureMetadata = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
if (!currentSession?.user) {
console.debug('[Auth Debug] No user session, skipping profile picture metadata fetch')
setProfilePictureMetadata(null)
return
}
try {
console.debug('[Auth Debug] Fetching profile picture metadata for user:', currentSession.user.id)
const metadata = await getProfilePictureMetadata(currentSession.user.id)
setProfilePictureMetadata(metadata)
console.debug('[Auth Debug] Profile picture metadata fetched:', metadata)
} catch (error: any) {
console.debug('[Auth Debug] Failed to fetch profile picture metadata:', error)
setProfilePictureMetadata(null)
}
}, [session])
const refreshProfilePictureMetadata = useCallback(async () => {
await fetchProfilePictureMetadata()
}, [fetchProfilePictureMetadata])
const updateCredits = useCallback((newBalance: number) => {
console.debug('[Auth Debug] Updating credit balance:', { from: creditBalance, to: newBalance })
setCreditBalance(newBalance)
// Also update the creditSummary if it exists
if (creditSummary) {
const updatedSummary: CreditSummary = {
...creditSummary,
creditsRemaining: newBalance,
currentCredits: newBalance
}
setCreditSummary(updatedSummary)
}
}, [creditSummary])
const hasSufficientCredits = useCallback((requiredCredits: number): CreditCheckResult => {
const currentBalance = creditBalance ?? 0
const hasSufficient = currentBalance >= requiredCredits
console.debug('[Auth Debug] Credit check:', { requiredCredits, currentBalance, hasSufficient })
return {
hasSufficientCredits: hasSufficient,
currentBalance,
requiredCredits,
shortfall: hasSufficient ? undefined : requiredCredits - currentBalance
}
}, [creditBalance])
const refreshSession = async () => {
try {
setLoading(true)
setError(null)
const { data, error } = await supabase.auth.refreshSession()
if (error) {
console.error('[Auth Debug] Session refresh error:', error)
setError(error)
setSession(null)
} else {
console.debug('[Auth Debug] Session refreshed successfully')
setSession(data.session)
}
} catch (err) {
console.error('[Auth Debug] Unexpected error during session refresh:', err)
setError(err as AuthError)
} finally {
setLoading(false)
}
}
const signOut = async () => {
try {
setError(null)
const { error } = await supabase.auth.signOut()
if (error) {
console.error('[Auth Debug] Sign out error:', error)
setError(error)
} else {
console.debug('[Auth Debug] Signed out successfully')
setSession(null)
}
} catch (err) {
console.error('[Auth Debug] Unexpected error during sign out:', err)
setError(err as AuthError)
}
}
// Set up global credit update callback
useEffect(() => {
setGlobalCreditUpdateCallback(updateCredits)
}, [updateCredits])
useEffect(() => {
let mounted = true
// Load current session on first mount
const initializeAuth = async () => {
try {
console.debug('[Auth Debug] Initializing auth...')
const { data, error } = await supabase.auth.getSession()
if (!mounted) return
if (error) {
console.error('[Auth Debug] Initial session error:', error)
setError(error)
} else {
console.debug('[Auth Debug] Initial session loaded:', {
hasSession: !!data.session,
userId: data.session?.user?.id,
email: data.session?.user?.email
})
setSession(data.session)
// Fetch credits, pro status, trial status, profile picture metadata, and profile picture using the session from the response
if (data.session?.user) {
// Sync OAuth avatar in background
syncOAuthAvatar(data.session.user).catch((err) => {
console.debug('[Auth Debug] Failed to sync OAuth avatar on init:', err)
})
await fetchCredits(data.session)
await fetchProStatus(data.session)
await fetchTrialStatus(data.session)
await fetchProfilePictureMetadata(data.session)
// Small delay to allow avatar sync to complete if quick
setTimeout(() => {
fetchProfilePicture(data.session)
}, 500)
}
}
} catch (err) {
console.error('[Auth Debug] Unexpected error during auth initialization:', err)
if (mounted) {
setError(err as AuthError)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
initializeAuth()
// Subscribe to auth state changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, newSession) => {
if (!mounted) return
console.debug('[Auth Debug] Auth state change:', {
event,
hasSession: !!newSession,
userId: newSession?.user?.id,
email: newSession?.user?.email,
timestamp: new Date().toISOString()
})
// Don't run supabase calls inside this callback; schedule them
setTimeout(() => {
if (mounted) {
setSession(newSession)
setError(null)
// Additional handling for specific events
if (event === 'SIGNED_OUT') {
console.debug('[Auth Debug] User signed out, clearing session')
// Clear credit data, pro status, trial status, profile picture, and metadata on sign out
setCreditBalance(null)
setCreditSummary(null)
setSubscription(null)
setIsPro(null)
setTrialStatus(null)
setProfilePictureUrl(null)
setProfilePictureMetadata(null)
} else if (event === 'SIGNED_IN') {
console.debug('[Auth Debug] User signed in successfully')
if (newSession?.user) {
setLoading(true)
// Sync OAuth avatar in background (don't block other fetches)
syncOAuthAvatar(newSession.user).catch((err) => {
console.debug('[Auth Debug] Failed to sync OAuth avatar:', err)
})
// Fetch user data in parallel
Promise.all([
fetchCredits(newSession),
fetchProStatus(newSession),
fetchTrialStatus(newSession),
fetchProfilePictureMetadata(newSession),
]).then(() => {
// Fetch profile picture AFTER sync has had time to complete
// Use a small delay to allow avatar sync to finish if it's quick
setTimeout(() => {
fetchProfilePicture(newSession).finally(() => {
setLoading(false)
console.debug('[Auth Debug] User data fully loaded after sign in')
})
}, 500)
})
}
} else if (event === 'TOKEN_REFRESHED') {
console.debug('[Auth Debug] Token refreshed')
// Optionally refresh credits, pro status, trial status, profile picture metadata, and profile picture on token refresh
if (newSession?.user) {
Promise.all([
fetchCredits(newSession),
fetchProStatus(newSession),
fetchTrialStatus(newSession),
fetchProfilePictureMetadata(newSession),
fetchProfilePicture(newSession)
]).then(() => {
console.debug('[Auth Debug] User data refreshed after token refresh')
})
}
} else if (event === 'USER_UPDATED') {
console.debug('[Auth Debug] User updated')
// Check if this is a pending OAuth upgrade completion
const pendingUpgrade = sessionStorage.getItem('pendingUpgrade')
const upgradeProvider = sessionStorage.getItem('upgradeProvider')
if (pendingUpgrade && newSession?.user && newSession.user.is_anonymous === false) {
console.debug('[Auth Debug] Processing pending OAuth upgrade:', upgradeProvider)
// Clear the flags first to prevent loops
sessionStorage.removeItem('pendingUpgrade')
sessionStorage.removeItem('upgradeProvider')
// Synchronize with backend
synchronizeUserUpgrade(upgradeProvider || undefined)
.then(() => {
console.debug('[Auth Debug] User upgrade synchronized successfully')
// Refresh credits, pro status, trial status, profile picture metadata, and profile picture after upgrade
if (newSession?.user) {
return Promise.all([
fetchCredits(newSession),
fetchProStatus(newSession),
fetchTrialStatus(newSession),
fetchProfilePictureMetadata(newSession),
fetchProfilePicture(newSession)
])
}
})
.then(() => {
console.debug('[Auth Debug] User data refreshed after upgrade')
})
.catch((err) => {
console.error('[Auth Debug] Failed to synchronize user upgrade:', err)
})
}
}
}
}, 0)
}
)
return () => {
mounted = false
subscription.unsubscribe()
}
}, [])
const value: AuthContextType = {
session,
user: session?.user ?? null,
loading,
error,
creditBalance,
subscription,
creditSummary,
isPro,
trialStatus,
profilePictureUrl,
profilePictureMetadata,
signOut,
refreshSession,
hasSufficientCredits,
updateCredits,
refreshCredits,
refreshProStatus,
refreshTrialStatus,
refreshProfilePicture,
refreshProfilePictureMetadata
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
// Debug hook to expose auth state for debugging
export function useAuthDebug() {
const auth = useAuth()
useEffect(() => {
console.debug('[Auth Debug] Current auth state:', {
hasSession: !!auth.session,
hasUser: !!auth.user,
loading: auth.loading,
hasError: !!auth.error,
userId: auth.user?.id,
email: auth.user?.email,
provider: auth.user?.app_metadata?.provider
})
}, [auth.session, auth.user, auth.loading, auth.error])
return auth
}
+165
View File
@@ -0,0 +1,165 @@
import { createClient } from '@supabase/supabase-js'
// Debug helper to log Supabase configuration
const debugConfig = () => {
const url = import.meta.env.VITE_SUPABASE_URL
const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY
console.log('[Supabase Debug] Configuration:', {
url: url ? '✓ URL configured' : '✗ URL missing',
key: key ? '✓ Key configured' : '✗ Key missing',
urlValue: url || 'undefined',
keyValue: key ? `${key.substring(0, 20)}...` : 'undefined'
})
return { url, key }
}
const config = debugConfig()
if (!config.url) {
throw new Error('Missing VITE_SUPABASE_URL environment variable')
}
if (!config.key) {
throw new Error('Missing VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY environment variable')
}
export const supabase = createClient(
config.url,
config.key,
{
auth: {
persistSession: true, // keep session in localStorage
autoRefreshToken: true,
detectSessionInUrl: true, // helpful on first load after redirect
// debug: import.meta.env.DEV, // Enable debug logs in development
},
}
)
// Debug helper for auth events
export const debugAuthEvents = () => {
supabase.auth.onAuthStateChange((event, session) => {
console.log('[Supabase Debug] Auth state change:', {
event,
hasSession: !!session,
userId: session?.user?.id,
email: session?.user?.email,
provider: session?.user?.app_metadata?.provider,
timestamp: new Date().toISOString()
})
})
}
// Debug auth events can be manually enabled by calling debugAuthEvents()
// Commented out to prevent excessive logging on every page load
// if (import.meta.env.DEV) {
// debugAuthEvents()
// }
// Anonymous authentication functions
export const signInAnonymously = async () => {
try {
const { data, error } = await supabase.auth.signInAnonymously()
if (error) {
console.error('[Supabase] Anonymous sign-in error:', error)
throw error
}
console.log('[Supabase] Anonymous sign-in successful:', {
userId: data.user?.id,
isAnonymous: data.user?.is_anonymous
})
return { data, error }
} catch (error) {
console.error('[Supabase] Anonymous sign-in failed:', error)
throw error
}
}
// Account linking functions
export const linkEmailIdentity = async (email: string, password?: string) => {
try {
const updateData: any = { email }
if (password) {
updateData.password = password
}
const { data, error } = await supabase.auth.updateUser(updateData)
if (error) {
console.error('[Supabase] Email linking error:', error)
throw error
}
// Refresh session to get updated token with new user metadata
const { error: refreshError } = await supabase.auth.refreshSession()
if (refreshError) {
console.warn('[Supabase] Session refresh after email linking failed:', refreshError)
// Don't throw - linking was successful, refresh is just for consistency
} else {
console.log('[Supabase] Session refreshed after email linking')
}
console.log('[Supabase] Email linked successfully:', {
email,
userId: data.user?.id
})
return { data, error }
} catch (error) {
console.error('[Supabase] Email linking failed:', error)
throw error
}
}
export const linkOAuthIdentity = async (provider: 'google' | 'github' | 'apple' | 'azure', redirectTo?: string) => {
try {
const { data, error } = await supabase.auth.linkIdentity({
provider: provider,
options: redirectTo ? { redirectTo } : undefined
})
if (error) {
console.error('[Supabase] OAuth linking error:', error)
throw error
}
console.log('[Supabase] OAuth identity linked successfully:', {
provider,
redirectTo,
url: data.url
})
return { data, error }
} catch (error) {
console.error('[Supabase] OAuth linking failed:', error)
throw error
}
}
// Helper function to check if user is anonymous
export const isUserAnonymous = (user: any) => {
return user?.is_anonymous === true
}
// Get current user session
export const getCurrentUser = async () => {
try {
const { data: { user }, error } = await supabase.auth.getUser()
if (error) {
console.error('[Supabase] Get user error:', error)
throw error
}
return user
} catch (error) {
console.error('[Supabase] Get user failed:', error)
throw error
}
}
@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useAuth } from '@app/auth/UseSession';
import SaasOnboardingModal from '@app/components/onboarding/SaasOnboardingModal';
const STORAGE_KEY = 'saas_onboarding_seen';
const ONBOARDING_SESSION_BLOCK_KEY = 'stirling-onboarding-session-active';
/**
* SaaS-only bootstrap to clear deferred tour requests, mark tool panel prompt as completed,
* and show SaaS-specific onboarding on first login.
*/
export default function OnboardingBootstrap() {
const { preferences, updatePreference } = usePreferences();
const { clearPendingTourRequest, setStartAfterToolModeSelection } = useOnboarding();
const { user, loading, trialStatus, isPro, refreshTrialStatus } = useAuth();
const [showModal, setShowModal] = useState(false);
const [isPolling, setIsPolling] = useState(false);
const [pollAttempts, setPollAttempts] = useState(0);
// Start polling when user logs in
useEffect(() => {
const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === 'true';
if (user && !hasSeenOnboarding && !loading && !isPolling && !showModal) {
console.debug('[Onboarding] Starting poll for trial data');
setIsPolling(true);
setPollAttempts(0);
}
}, [user, loading, isPolling, showModal]);
// Poll for trial data
useEffect(() => {
if (!isPolling) return;
const pollInterval = 500; // Check every 500ms
const timer = setTimeout(async () => {
const newAttempts = pollAttempts + 1;
console.debug('[Onboarding] Polling for trial data, attempt:', newAttempts);
await refreshTrialStatus();
setPollAttempts(newAttempts);
// Check will happen in the next effect
}, pollInterval);
return () => clearTimeout(timer);
}, [isPolling, pollAttempts, refreshTrialStatus]);
// Stop polling when data arrives or timeout
useEffect(() => {
if (!isPolling) return;
const hasData = trialStatus !== undefined && trialStatus !== null;
const hasProStatus = isPro !== null;
const maxAttempts = 10;
if (hasData || pollAttempts >= maxAttempts) {
console.debug('[Onboarding] Trial data ready or timeout, showing modal', {
hasData,
hasProStatus,
attempts: pollAttempts,
trialStatus,
isPro
});
setIsPolling(false);
setShowModal(true);
}
}, [isPolling, trialStatus, isPro, pollAttempts]);
const handleClose = () => {
localStorage.setItem(STORAGE_KEY, 'true');
setShowModal(false);
};
// Keep existing logic to disable core onboarding flags
useEffect(() => {
// Ensure tool panel preference is set so tours are never deferred.
if (!preferences.toolPanelModePromptSeen || !preferences.hasSelectedToolPanelMode) {
updatePreference('toolPanelModePromptSeen', true);
updatePreference('hasSelectedToolPanelMode', true);
}
// Clear any lingering deferred tour requests.
clearPendingTourRequest();
setStartAfterToolModeSelection(false);
// In SaaS, skip the core intro onboarding entirely.
if (!preferences.hasSeenIntroOnboarding) {
updatePreference('hasSeenIntroOnboarding', true);
}
// Also mark completed to avoid follow-up banners/modals.
if (!preferences.hasCompletedOnboarding) {
updatePreference('hasCompletedOnboarding', true);
}
// Also clear any session flag that might mark onboarding as active.
if (typeof window !== 'undefined') {
window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
}
}, [
preferences.hasSelectedToolPanelMode,
preferences.toolPanelModePromptSeen,
preferences.hasSeenIntroOnboarding,
preferences.hasCompletedOnboarding,
updatePreference,
clearPendingTourRequest,
setStartAfterToolModeSelection,
]);
// Only render modal when it should be shown to avoid running hooks unnecessarily
return showModal ? <SaasOnboardingModal opened={showModal} onClose={handleClose} /> : null;
}
@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { useAuth } from '@app/auth/UseSession';
import { TrialExpiredModal } from '@app/components/shared/TrialExpiredModal';
import StripeCheckout from '@app/components/shared/StripeCheckoutSaas';
/**
* Bootstrap component that shows the trial expired modal when a user's trial has ended
* and they haven't added a payment method. Shows once per user per expired trial.
*/
export default function TrialExpiredBootstrap() {
const { user, trialStatus, isPro } = useAuth();
const [showModal, setShowModal] = useState(false);
const [checkoutOpened, setCheckoutOpened] = useState(false);
useEffect(() => {
// Close modal if user logs out or session expires
if (!user) {
if (showModal) {
console.debug('[TrialExpired] User logged out, closing modal');
setShowModal(false);
}
if (checkoutOpened) {
setCheckoutOpened(false);
}
return;
}
// Only check conditions when auth is fully loaded
if (trialStatus === null || isPro === null) {
return;
}
// Build localStorage key unique to this user
const storageKey = `trialExpiredModalShown_${user.id}`;
const hasSeenModal = localStorage.getItem(storageKey) === 'true';
// If user is currently trialing, clear any previous "seen" flag
// This handles the edge case where a user might re-enter a trial
if (trialStatus.isTrialing) {
if (hasSeenModal) {
console.debug('[TrialExpired] User is trialing, clearing seen flag');
localStorage.removeItem(storageKey);
}
return;
}
// Check if all conditions are met to show the modal
const isExpired =
trialStatus.status === 'incomplete_expired' || trialStatus.status === 'canceled';
const hasNoPayment = !trialStatus.hasPaymentMethod && !trialStatus.hasScheduledSub;
const wasDowngraded = !isPro;
const trialEndedRecently = trialStatus.daysRemaining === 0;
const shouldShowModal =
isExpired && hasNoPayment && wasDowngraded && trialEndedRecently && !hasSeenModal;
if (shouldShowModal) {
console.debug('[TrialExpired] Showing trial expired modal', {
status: trialStatus.status,
daysRemaining: trialStatus.daysRemaining,
hasPaymentMethod: trialStatus.hasPaymentMethod,
hasScheduledSub: trialStatus.hasScheduledSub,
isPro,
});
setShowModal(true);
}
}, [user, trialStatus, isPro, showModal, checkoutOpened]);
const handleClose = () => {
if (user) {
const storageKey = `trialExpiredModalShown_${user.id}`;
localStorage.setItem(storageKey, 'true');
console.debug('[TrialExpired] Modal dismissed, marking as seen');
}
setShowModal(false);
};
const handleSubscribe = () => {
console.debug('[TrialExpired] User clicked Subscribe to Pro');
setCheckoutOpened(true);
};
const handleCheckoutSuccess = () => {
console.debug('[TrialExpired] Subscription successful, refreshing page');
// Close modal and refresh to update subscription status
handleClose();
window.location.reload();
};
const handleCheckoutClose = () => {
console.debug('[TrialExpired] Checkout closed');
setCheckoutOpened(false);
};
return (
<>
<TrialExpiredModal
opened={showModal && !checkoutOpened}
onClose={handleClose}
onSubscribe={handleSubscribe}
/>
{user && (
<StripeCheckout
opened={checkoutOpened}
onClose={handleCheckoutClose}
purchaseType="subscription"
planId="pro"
creditsPack={null}
planName="Pro"
onSuccess={handleCheckoutSuccess}
onError={(error) => console.error('[TrialExpired] Checkout error:', error)}
isTrialConversion={false} // Trial already ended, so this is not a conversion
/>
)}
</>
);
}
@@ -0,0 +1,90 @@
.guest-banner {
position: fixed;
top: 1rem;
right: 4.5rem;
z-index: 1000;
background: var(--modal-content-bg, #111418);
border: 1px solid var(--api-keys-card-border, rgba(255,255,255,0.08));
border-radius: 12px;
box-shadow: 0 6px 24px rgba(0,0,0,0.35);
padding: 12px 16px;
max-width: 30rem;
width: 30rem;
color: var(--mantine-color-text);
}
.guest-banner-content {
display: flex;
align-items: center;
gap: 12px;
}
.guest-banner-text {
flex: 1;
min-width: 0;
}
.guest-banner-title {
font-size: 13px;
font-weight: 600;
color: var(--mantine-color-text);
margin-bottom: 4px;
line-height: 1.3;
}
.guest-banner-message {
font-size: 12px;
color: var(--mantine-color-dimmed);
line-height: 1.4;
margin-bottom: 8px;
}
.guest-banner-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
.guest-banner-dismiss {
padding: 6px;
border-radius: 8px;
background: transparent;
color: var(--mantine-color-dimmed);
border: none;
cursor: pointer;
}
.guest-banner-dismiss:hover {
background: var(--mantine-color-gray-1, rgba(255,255,255,0.05));
}
.guest-banner-signup {
white-space: nowrap;
padding: 6px 12px;
border-radius: 8px;
background: var(--blue-6, #2563eb);
color: #fff;
border: none;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.guest-banner-signup:hover {
background: var(--blue-7, #1d4ed8);
}
.guest-banner-icon {
width: 16px;
height: 16px;
}
.guest-banner-signup-icon {
width: 14px;
height: 14px;
}
@@ -0,0 +1,88 @@
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CloseIcon from '@mui/icons-material/Close'
import PersonAddIcon from '@mui/icons-material/PersonAdd'
import { useAuth } from '@app/auth/UseSession'
import { isUserAnonymous } from '@app/auth/supabase'
import { withBasePath } from '@app/constants/app'
import '@app/components/auth/GuestUserBanner.css'
interface GuestUserBannerProps {
className?: string
}
// Ensure the toast only appears once per full page load, not on re-hydration
let hasShownThisLoad = false
/**
* Guest user toast encouraging account creation.
* Appears 2s after load, top-right of the viewport, offset by right rail.
*/
export function GuestUserBanner({ className = '' }: GuestUserBannerProps) {
const { t } = useTranslation()
const { session } = useAuth()
const [isDismissed, setIsDismissed] = useState(false)
const [visible, setVisible] = useState(false)
const isAnon = Boolean(session?.user && isUserAnonymous(session.user))
useEffect(() => {
if (!isAnon || hasShownThisLoad) return
const timer = setTimeout(() => {
setVisible(true)
hasShownThisLoad = true
}, 2000)
return () => clearTimeout(timer)
}, [isAnon])
if (!isAnon || isDismissed || !visible) {
return null
}
const handleSignUp = () => {
window.location.href = withBasePath('/signup')
}
const handleDismiss = () => {
setIsDismissed(true)
}
return (
<div
className={`guest-banner ${className || ''}`}
role="status"
aria-live="polite"
>
<div className="guest-banner-content">
<div className="guest-banner-text">
<div className="guest-banner-title">
{t('guestBanner.title', "You're using Stirling PDF as a guest!")}
</div>
<div className="guest-banner-message">
{t('guestBanner.message', 'Create a free account to save your work, access more features, and support the project.')}
</div>
</div>
<div className="guest-banner-actions">
<button
onClick={handleDismiss}
aria-label={t('guestBanner.dismiss', 'Dismiss banner')}
className="guest-banner-dismiss"
>
<CloseIcon className="guest-banner-icon" />
</button>
<button
onClick={handleSignUp}
className="guest-banner-signup"
>
<PersonAddIcon className="guest-banner-signup-icon" />
{t('guestBanner.signUp', 'Sign Up Free')}
</button>
</div>
</div>
</div>
)
}
export default GuestUserBanner
@@ -0,0 +1,44 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession'
import { useAutoAnonymousAuth } from '@app/hooks/useAutoAnonymousAuth'
interface RequireAuthProps {
fallbackPath?: string
}
export function RequireAuth({ fallbackPath = '/login' }: RequireAuthProps) {
const { session, loading } = useAuth()
const location = useLocation()
const { isAutoAuthenticating } = useAutoAnonymousAuth()
// Safe development-only auth bypass
const isLocalhost = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname)
const devBypassEnabled = Boolean(import.meta.env.DEV && isLocalhost && import.meta.env.VITE_DEV_BYPASS_AUTH === 'true')
if (devBypassEnabled) {
console.warn('[RequireAuth] DEV BYPASS ACTIVE — allowing access without session on localhost')
return <Outlet />
}
// Wait for both auth bootstrap and auto-anon to finish
if (loading || isAutoAuthenticating) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin h-6 w-6 mx-auto mb-3 border-2 border-gray-300 border-t-transparent rounded-full" />
<p className="text-gray-600">Preparing your session</p>
</div>
</div>
)
}
if (!session) {
// Change the URL to /login
return <Navigate to={fallbackPath} replace state={{ from: location }} />
}
// Render protected routes
return <Outlet />
}
export default RequireAuth
@@ -0,0 +1,57 @@
import { useEffect, useRef } from 'react';
import Userback from '@userback/widget';
import { useAuth } from '@app/auth/UseSession';
interface UserbackWidgetProps {
token: string;
}
interface UserbackInstance {
destroy: () => void;
}
export default function UserbackWidget({ token }: UserbackWidgetProps) {
const { user } = useAuth();
const userbackRef = useRef<UserbackInstance | null>(null);
const initializingRef = useRef(false);
useEffect(() => {
if (!user || initializingRef.current) return;
initializingRef.current = true;
const initializeUserback = async () => {
try {
// Prepare user data options
const userInfo: { name?: string; email?: string } = {};
if (user.user_metadata?.full_name) userInfo.name = user.user_metadata.full_name;
if (user.email) userInfo.email = user.email;
const options = {
user_data: {
id: user.id,
info: userInfo
}
};
// Initialize Userback
userbackRef.current = await Userback(token, options);
}
finally {
initializingRef.current = false;
}
};
initializeUserback();
// Cleanup function
return () => {
if (userbackRef.current && typeof userbackRef.current.destroy === 'function') {
userbackRef.current.destroy();
}
initializingRef.current = false;
};
}, [user, token]);
return null; // This component doesn't render anything visible
}
@@ -0,0 +1,6 @@
import UserbackWidget from '@app/components/feedback/UserbackWidget';
export function HomePageExtensions() {
const userbackToken = import.meta.env.VITE_USERBACK_TOKEN;
return userbackToken ? <UserbackWidget token={userbackToken} /> : null;
}
@@ -0,0 +1,7 @@
/**
* SaaS stub — core tour system is suppressed in SaaS.
* SaaS uses SaasOnboardingModal instead.
*/
export default function OnboardingTour() {
return null;
}
@@ -0,0 +1,131 @@
import React from 'react';
import { Modal, Stack } from '@mantine/core';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
import { renderButtons } from '@app/components/onboarding/renderButtons';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import { useSaasOnboardingState } from '@app/components/onboarding/useSaasOnboardingState';
import { BASE_PATH } from '@app/constants/app';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
interface SaasOnboardingModalProps {
opened: boolean;
onClose: () => void;
}
export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
const { t } = useTranslation();
const flow = useSaasOnboardingState(props);
if (!flow) {
return null;
}
const {
currentStep,
totalSteps,
currentSlide,
slideDefinition,
flowState,
handleButtonAction,
} = flow;
const renderHero = () => {
if (slideDefinition.hero.type === 'dual-icon') {
return (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
</div>
</div>
);
}
return (
<div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === 'rocket' && (
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
</div>
);
};
return (
<Modal
opened={props.opened}
onClose={props.onClose}
closeOnClickOutside={false}
centered
size="lg"
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0 },
content: {
overflow: 'hidden',
border: 'none',
background: 'var(--bg-surface)',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
},
}}
>
<Stack gap={0} className={styles.modalContent} style={{ height: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}>
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
<AnimatedSlideBackground
gradientStops={currentSlide.background.gradientStops}
circles={currentSlide.background.circles}
isActive
slideKey={currentSlide.key}
/>
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
{renderHero()}
</div>
</div>
<div
className={styles.modalBody}
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
WebkitOverflowScrolling: 'touch',
}}
>
<Stack gap={16}>
<div
key={`title-${currentSlide.key}`}
className={`${styles.title} ${styles.titleText}`}
>
{currentSlide.title}
</div>
<div className={styles.bodyText}>
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{currentSlide.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<OnboardingStepper totalSteps={totalSteps} activeStep={currentStep} />
<div className={styles.buttonContainer}>
{renderButtons({
slideDefinition,
flowState,
onAction: handleButtonAction,
t,
})}
</div>
</Stack>
</div>
</Stack>
</Modal>
);
}
@@ -0,0 +1,95 @@
import React from 'react';
import { Button, Group, ActionIcon } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import { TFunction } from 'i18next';
import { ButtonDefinition, type FlowState, type ButtonAction } from '@app/components/onboarding/saasOnboardingFlowConfig';
interface RenderButtonsProps {
slideDefinition: {
buttons: ButtonDefinition[];
id: string;
};
flowState: FlowState;
onAction: (action: ButtonAction) => void;
t: TFunction;
}
export function renderButtons({ slideDefinition, flowState, onAction, t }: RenderButtonsProps) {
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
const buttonStyles = (variant: ButtonDefinition['variant']) =>
variant === 'primary'
? {
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
},
}
: {
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
};
const resolveButtonLabel = (button: ButtonDefinition) => {
// Translate the label (it's a translation key)
const label = button.label ?? '';
if (!label) return '';
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
const fallback = label.split('.').pop() || label;
return t(label, fallback);
};
const renderButton = (button: ButtonDefinition) => {
const disabled = button.disabledWhen?.(flowState) ?? false;
if (button.type === 'icon') {
return (
<ActionIcon
key={button.key}
onClick={() => onAction(button.action)}
radius="md"
size={40}
disabled={disabled}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
}}
>
{button.icon === 'chevron-left' && <ChevronLeftIcon fontSize="small" />}
</ActionIcon>
);
}
const variant = button.variant ?? 'secondary';
const label = resolveButtonLabel(button);
return (
<Button key={button.key} onClick={() => onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
{label}
</Button>
);
};
if (leftButtons.length === 0) {
return <Group justify="flex-end">{rightButtons.map(renderButton)}</Group>;
}
if (rightButtons.length === 0) {
return <Group justify="flex-start">{leftButtons.map(renderButton)}</Group>;
}
return (
<Group justify="space-between">
<Group gap={12}>{leftButtons.map(renderButton)}</Group>
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
</Group>
);
}
@@ -0,0 +1,40 @@
import { TrialStatus } from '@app/auth/UseSession';
import { FLOW_SEQUENCES, SlideId } from '@app/components/onboarding/saasOnboardingFlowConfig';
export interface FlowConfig {
type: 'saas-trial' | 'saas-paid';
ids: SlideId[];
}
/**
* Resolves the appropriate onboarding flow based on user's subscription status.
*
* @param trialStatus - User's trial information from Supabase
* @param _isPro - Whether user has Pro subscription
* @returns FlowConfig with the appropriate slide sequence
*/
export function resolveSaasFlow(
trialStatus: TrialStatus | null,
_isPro: boolean | null
): FlowConfig {
// Show free trial card if:
// 1. User has active trial (isTrialing = true)
// 2. Trial has not expired (daysRemaining > 0)
// 3. User is not paid Pro (or Pro is from trial)
const hasActiveTrial =
trialStatus?.isTrialing === true &&
trialStatus.daysRemaining > 0;
if (hasActiveTrial) {
return {
type: 'saas-trial',
ids: FLOW_SEQUENCES.saasTrialUser,
};
}
// For paid users, expired trials, or no trial info
return {
type: 'saas-paid',
ids: FLOW_SEQUENCES.saasPaidUser,
};
}
@@ -0,0 +1,134 @@
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
import FreeTrialSlide from '@app/components/onboarding/slides/FreeTrialSlide';
import { SlideConfig } from '@app/types/types';
import { TrialStatus } from '@app/auth/UseSession';
export type SlideId = 'welcome' | 'free-trial' | 'desktop-install';
export type HeroType = 'rocket' | 'dual-icon' | 'diamond';
export type ButtonAction =
| 'next'
| 'prev'
| 'close'
| 'download-selected';
export type FlowState = Record<string, never>;
export interface OSOption {
label: string;
url: string;
value: string;
}
export interface SlideFactoryParams {
osLabel: string;
osUrl: string;
osOptions?: OSOption[];
onDownloadUrlChange?: (url: string) => void;
trialStatus?: TrialStatus | null;
}
export interface HeroDefinition {
type: HeroType;
}
export interface ButtonDefinition {
key: string;
type: 'button' | 'icon';
label?: string;
icon?: 'chevron-left';
variant?: 'primary' | 'secondary' | 'default';
group: 'left' | 'right';
action: ButtonAction;
disabledWhen?: (state: FlowState) => boolean;
}
export interface SlideDefinition {
id: SlideId;
createSlide: (params: SlideFactoryParams) => SlideConfig;
hero: HeroDefinition;
buttons: ButtonDefinition[];
}
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
'welcome': {
id: 'welcome',
createSlide: () => WelcomeSlide(),
hero: { type: 'rocket' },
buttons: [
{
key: 'welcome-next',
type: 'button',
label: 'onboarding.buttons.next',
variant: 'primary',
group: 'right',
action: 'next',
},
],
},
'free-trial': {
id: 'free-trial',
createSlide: ({ trialStatus }) => {
if (!trialStatus) {
throw new Error('Trial status is required for free-trial slide');
}
return FreeTrialSlide({ trialStatus });
},
hero: { type: 'diamond' },
buttons: [
{
key: 'trial-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{
key: 'trial-next',
type: 'button',
label: 'onboarding.buttons.next',
variant: 'primary',
group: 'right',
action: 'next',
},
],
},
'desktop-install': {
id: 'desktop-install',
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) =>
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
hero: { type: 'dual-icon' },
buttons: [
{
key: 'desktop-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{
key: 'desktop-skip',
type: 'button',
label: 'onboarding.buttons.skipForNow',
variant: 'secondary',
group: 'left',
action: 'close',
},
{
key: 'desktop-download',
type: 'button',
label: 'onboarding.buttons.download',
variant: 'primary',
group: 'right',
action: 'download-selected',
},
],
},
};
export const FLOW_SEQUENCES = {
saasTrialUser: ['welcome', 'free-trial', 'desktop-install'] as SlideId[],
saasPaidUser: ['welcome', 'desktop-install'] as SlideId[],
};
@@ -0,0 +1,81 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SlideConfig } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import { TrialStatus } from '@app/auth/UseSession';
interface FreeTrialSlideProps {
trialStatus: TrialStatus;
}
function FreeTrialSlideTitle() {
const { t } = useTranslation();
return (
<span>
{t('onboarding.freeTrial.title', 'Your 30-Day Pro Trial')}
</span>
);
}
const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
const { t } = useTranslation();
// Format the trial end date
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
// Determine which message to show based on payment method
const afterTrialMessage = trialStatus.hasScheduledSub
? t('onboarding.freeTrial.afterTrialWithPayment', 'Your Pro subscription will start automatically when the trial ends.')
: trialStatus.hasPaymentMethod
? t('onboarding.freeTrial.afterTrialWithPayment', 'Your Pro subscription will start automatically when the trial ends.')
: t('onboarding.freeTrial.afterTrialWithoutPayment', 'After your trial ends, you\'ll continue with our free tier. Add a payment method to keep Pro access.');
// Pluralize days remaining
const daysText = trialStatus.daysRemaining === 1
? t('onboarding.freeTrial.daysRemainingSingular', '{{days}} day remaining', { days: trialStatus.daysRemaining })
: t('onboarding.freeTrial.daysRemaining', '{{days}} days remaining', { days: trialStatus.daysRemaining });
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<p>
{t(
'onboarding.freeTrial.body',
'You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing.'
)}
</p>
<div style={{
background: 'rgba(255, 255, 255, 0.1)',
borderRadius: '8px',
padding: '1rem',
textAlign: 'center'
}}>
<div style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
{daysText}
</div>
<div style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{t('onboarding.freeTrial.trialEnds', 'Trial ends {{date}}', { date: trialEndDate })}
</div>
</div>
<p style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{afterTrialMessage}
</p>
</div>
);
};
export default function FreeTrialSlide({ trialStatus }: FreeTrialSlideProps): SlideConfig {
return {
key: 'free-trial',
title: <FreeTrialSlideTitle />,
body: <FreeTrialSlideBody trialStatus={trialStatus} />,
background: {
gradientStops: ['#10B981', '#06B6D4'],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { useAuth } from '@app/auth/UseSession';
import { useOs } from '@app/hooks/useOs';
import {
SLIDE_DEFINITIONS,
type ButtonAction,
type FlowState,
type SlideId,
} from '@app/components/onboarding/saasOnboardingFlowConfig';
import { resolveSaasFlow } from '@app/components/onboarding/saasFlowResolver';
import { DOWNLOAD_URLS } from '@app/constants/downloads';
interface UseSaasOnboardingStateResult {
currentStep: number;
totalSteps: number;
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
flowState: FlowState;
handleButtonAction: (action: ButtonAction) => void;
}
interface UseSaasOnboardingStateProps {
opened: boolean;
onClose: () => void;
}
export function useSaasOnboardingState({
opened,
onClose,
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
const { trialStatus, isPro, loading } = useAuth();
const osType = useOs();
const selectedDownloadUrlRef = useRef<string>('');
const [currentStep, setCurrentStep] = useState<number>(0);
// Reset state when modal closes
useEffect(() => {
if (!opened) {
setCurrentStep(0);
}
}, [opened]);
// Determine OS details for desktop download
const os = useMemo(() => {
switch (osType) {
case 'windows':
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
case 'mac-apple':
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
case 'mac-intel':
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
case 'linux-x64':
case 'linux-arm64':
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
default:
return { label: '', url: '' };
}
}, [osType]);
const osOptions = useMemo(() => {
const options = [
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
];
return options.filter(opt => opt.url);
}, []);
// Store selected download URL
const handleDownloadUrlChange = useCallback((url: string) => {
selectedDownloadUrlRef.current = url;
}, []);
// Resolve flow based on trial status
const resolvedFlow = useMemo(
() => resolveSaasFlow(trialStatus, isPro),
[trialStatus, isPro]
);
const flowSlideIds = resolvedFlow.ids;
const totalSteps = flowSlideIds.length;
const maxIndex = Math.max(totalSteps - 1, 0);
// Ensure current step is within bounds
useEffect(() => {
if (currentStep >= flowSlideIds.length) {
setCurrentStep(Math.max(flowSlideIds.length - 1, 0));
}
}, [flowSlideIds.length, currentStep]);
const currentSlideId = flowSlideIds[currentStep] ?? flowSlideIds[flowSlideIds.length - 1];
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
// Create slide with appropriate params - must be called before any early returns
const currentSlide = useMemo(() => {
if (!slideDefinition) return null;
return slideDefinition.createSlide({
osLabel: os.label,
osUrl: os.url,
osOptions,
onDownloadUrlChange: handleDownloadUrlChange,
trialStatus: trialStatus ?? undefined,
});
}, [slideDefinition, os.label, os.url, osOptions, handleDownloadUrlChange, trialStatus]);
// Navigation functions
const goNext = useCallback(() => {
setCurrentStep((prev) => Math.min(prev + 1, maxIndex));
}, [maxIndex]);
const goPrev = useCallback(() => {
setCurrentStep((prev) => Math.max(prev - 1, 0));
}, []);
// Handle button actions
const handleButtonAction = useCallback(
(action: ButtonAction) => {
switch (action) {
case 'next':
// If on last slide, close modal
if (currentStep === maxIndex) {
onClose();
} else {
goNext();
}
return;
case 'prev':
goPrev();
return;
case 'close':
onClose();
return;
case 'download-selected': {
// Open download URL in new tab
const downloadUrl = selectedDownloadUrlRef.current || os.url;
if (downloadUrl) {
window.open(downloadUrl, '_blank', 'noopener,noreferrer');
}
// Then advance to next slide or close if last
if (currentStep === maxIndex) {
onClose();
} else {
goNext();
}
return;
}
default:
console.warn(`Unhandled button action: ${action}`);
return;
}
},
[currentStep, maxIndex, goNext, goPrev, onClose, os.url]
);
const flowState: FlowState = {};
// Early return after all hooks have been called
if (!slideDefinition || !currentSlide || loading) {
return null;
}
return {
currentStep,
totalSteps,
slideDefinition,
currentSlide,
flowState,
handleButtonAction,
};
}
@@ -0,0 +1,238 @@
import React, { useCallback, useMemo, useState, useEffect } from 'react';
import { Modal, Button, Text, ActionIcon } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { useAuth } from '@app/auth/UseSession';
import { isUserAnonymous } from '@app/auth/supabase';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import Overview from '@app/components/shared/config/configSections/Overview';
import { createSaasConfigNavSections } from '@app/components/shared/config/saasConfigNavSections';
import { NavKey } from '@app/components/shared/config/types';
import { withBasePath } from '@app/constants/app';
import '@app/components/shared/AppConfigModal.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
interface AppConfigModalProps {
opened: boolean;
onClose: () => void;
}
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const isMobile = useMediaQuery("(max-width: 1024px)");
const { signOut, user, creditBalance, refreshCredits } = useAuth();
const { t } = useTranslation();
const [confirmOpen, setConfirmOpen] = useState(false);
const [active, setActive] = useState<NavKey>('overview');
const [notice, setNotice] = useState<string | null>(null);
// Check if user can access billing features (non-anonymous users only)
const isAnonymous = user ? isUserAnonymous(user) : false;
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
if (detail?.key) {
setActive(detail.key);
}
};
window.addEventListener('appConfig:navigate', handler as EventListener);
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
}, []);
// Listen for notice updates (e.g., "Not enough credits..." next to Plan title)
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as { key?: NavKey; notice?: string } | undefined;
if (detail?.notice && (detail?.key ? detail.key === 'plan' : true)) {
setNotice(detail.notice);
}
};
window.addEventListener('appConfig:notice', handler as EventListener);
return () => window.removeEventListener('appConfig:notice', handler as EventListener);
}, []);
// When the modal opens to Plan, proactively refresh credits and log values
useEffect(() => {
if (!opened) return;
if (active !== 'plan') return;
console.log('[AppConfigModal] Opening Plan section. Current creditBalance:', creditBalance);
(async () => {
try {
await refreshCredits();
} catch (e) {
console.warn('[AppConfigModal] Failed to refresh credits on Plan open:', e);
}
})();
}, [opened, active]);
useEffect(() => {
if (!opened) return;
if (active !== 'plan') return;
console.log('[AppConfigModal] Credit balance updated while viewing Plan:', creditBalance);
}, [opened, active, creditBalance]);
const colors = useMemo(() => ({
navBg: 'var(--modal-nav-bg)',
sectionTitle: 'var(--modal-nav-section-title)',
navItem: 'var(--modal-nav-item)',
navItemActive: 'var(--modal-nav-item-active)',
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
contentBg: 'var(--modal-content-bg)',
headerBorder: 'var(--modal-header-border)',
}), []);
const isDev = process.env.NODE_ENV === 'development';
const openLogoutConfirm = useCallback(() => setConfirmOpen(true), []);
// Left navigation structure and icons
const configNavSections = useMemo(
() =>
createSaasConfigNavSections(Overview, openLogoutConfirm, {
isDev,
isAnonymous,
t,
}),
[openLogoutConfirm, isDev, isAnonymous, t],
);
const activeLabel = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
if (found) return found.label;
}
return '';
}, [configNavSections, active]);
const activeComponent = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
if (found) return found.component;
}
return null;
}, [configNavSections, active]);
return (
<>
<Modal
opened={opened}
onClose={onClose}
title={null}
size={isMobile ? "100%" : 980}
centered
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
>
<div className="modal-container">
{/* Left navigation */}
<div
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
style={{
background: colors.navBg,
borderRight: `1px solid ${colors.headerBorder}`,
}}
>
<div className="modal-nav-scroll">
{configNavSections.map(section => (
<div key={section.title} className="modal-nav-section">
{!isMobile && (
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{section.title}
</Text>
)}
<div className="modal-nav-section-items">
{section.items.map(item => {
const isActive = active === item.key;
const color = isActive ? colors.navItemActive : colors.navItem;
const iconSize = isMobile ? 28 : 18;
return (
<div
key={item.key}
onClick={() => setActive(item.key)}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
}}
>
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
{!isMobile && (
<Text size="sm" fw={500} style={{ color }}>
{item.label}
</Text>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
{/* Right content */}
<div className="modal-content">
<div className="modal-content-scroll">
{/* Sticky header with section title and small close button */}
<div
className="modal-header"
style={{
background: colors.contentBg,
borderBottom: `1px solid ${colors.headerBorder}`,
}}
>
<Text fw={700} size="lg">
{activeLabel}
{active === 'plan' && notice ? (
<span style={{ marginLeft: 8, fontWeight: 600, color: 'var(--mantine-color-yellow-7)' }}>
{notice}
</span>
) : null}
</Text>
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
<LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon>
</div>
<div className="modal-body">
{activeComponent}
</div>
</div>
</div>
</div>
</Modal>
{/* Confirm logout modal */}
<Modal
opened={confirmOpen}
onClose={() => setConfirmOpen(false)}
title="Sign out"
centered
zIndex={Z_INDEX_OVER_SETTINGS_MODAL}
>
<div className="confirm-modal-content">
<Text>Are you sure you want to sign out?</Text>
<div className="confirm-modal-buttons">
<Button variant="default" onClick={() => setConfirmOpen(false)}>Cancel</Button>
<Button
color="red"
onClick={async () => {
try {
await signOut();
} finally {
setConfirmOpen(false);
onClose();
window.location.href = withBasePath('/login');
}
}}
>
Sign out
</Button>
</div>
</div>
</Modal>
</>
);
};
export default AppConfigModal;
@@ -0,0 +1,179 @@
import React, { ReactNode } from 'react';
import { Paper, Group, Text, Button, ActionIcon, Stack } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
type InfoBannerTone = 'info' | 'warning';
const toneStyles: Record<
InfoBannerTone,
{
background: string;
border: string;
text: string;
icon: string;
buttonColor: string;
}
> = {
info: {
background: 'var(--mantine-color-blue-0)',
border: 'var(--mantine-color-blue-2)',
text: 'var(--mantine-color-blue-9)',
icon: 'var(--mantine-color-blue-6)',
buttonColor: 'blue',
},
warning: {
background: 'var(--mantine-color-orange-0)',
border: 'var(--mantine-color-orange-3)',
text: 'var(--mantine-color-orange-9)',
icon: 'var(--mantine-color-orange-7)',
buttonColor: 'orange',
},
};
interface InfoBannerProps {
icon?: string | ReactNode; // SaaS supports ReactNode (e.g., logo images)
title?: ReactNode;
message: ReactNode;
buttonText?: string;
buttonIcon?: string;
onButtonClick?: () => void;
onDismiss?: () => void;
dismissible?: boolean;
loading?: boolean;
show?: boolean;
tone?: InfoBannerTone;
background?: string;
borderColor?: string;
textColor?: string;
iconColor?: string;
buttonColor?: string;
buttonVariant?: 'light' | 'filled' | 'white' | 'outline' | 'subtle';
buttonTextColor?: string; // SaaS-specific for dark theme buttons
minHeight?: number | string;
closeIconColor?: string; // SaaS-specific for dark theme
}
/**
* SaaS-specific info banner with enhanced theming support
* Supports ReactNode icons (e.g., logo images) and custom button text colors
*/
export const InfoBanner: React.FC<InfoBannerProps> = ({
icon,
title,
message,
buttonText,
buttonIcon = 'check-circle-rounded',
onButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = 'info',
background,
borderColor,
textColor,
iconColor,
buttonColor,
buttonVariant = 'light',
buttonTextColor,
minHeight = 56,
closeIconColor,
}) => {
if (!show) {
return null;
}
const toneStyle = toneStyles[tone] ?? toneStyles.info;
const handleDismiss = () => {
onDismiss?.();
};
return (
<Paper
p="sm"
radius={0}
style={{
background: background ?? toneStyle.background,
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
minHeight,
display: 'flex',
alignItems: 'center',
}}
>
<Group gap="sm" align="center" wrap="nowrap" justify="space-between" style={{ width: '100%' }}>
<Group gap="sm" align="center" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{icon && (
typeof icon === 'string' ? (
<LocalIcon
icon={icon}
width="1.2rem"
height="1.2rem"
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
) : (
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{icon}
</div>
)
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text fw={600} size="sm" style={{ color: textColor ?? toneStyle.text }}>
{title}
</Text>
)}
<Text
fw={title ? 400 : 500}
size="sm"
style={{ color: textColor ?? toneStyle.text }}
lineClamp={2}
>
{message}
</Text>
</Stack>
</Group>
<Group gap="xs" align="center" wrap="nowrap">
{buttonText && onButtonClick && (
<Button
variant={buttonVariant}
color={buttonColor ?? toneStyle.buttonColor}
size="xs"
onClick={onButtonClick}
loading={loading}
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
styles={
buttonTextColor
? {
label: {
color: buttonTextColor,
},
}
: buttonVariant !== 'white' && buttonVariant !== 'filled'
? {
label: {
color: textColor ?? toneStyle.text,
},
}
: undefined
}
>
{buttonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="subtle"
color={closeIconColor ? undefined : 'gray'}
size="sm"
onClick={handleDismiss}
aria-label="Dismiss"
style={closeIconColor ? { color: closeIconColor } : undefined}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
)}
</Group>
</Group>
</Paper>
);
};
@@ -0,0 +1,64 @@
import { useState } from 'react';
import { supabase } from '@app/auth/supabase';
import { Button } from '@mantine/core';
import { usePlans } from '@app/hooks/usePlans';
interface TrialStatus {
isTrialing: boolean;
trialEnd: string;
daysRemaining: number;
hasPaymentMethod: boolean;
hasScheduledSub: boolean;
}
export function ManageBillingButton({
returnUrl = typeof window !== 'undefined' ? window.location.href : '/',
children = 'Manage billing',
trialStatus,
}: {
returnUrl?: string;
children?: React.ReactNode;
trialStatus?: TrialStatus;
}) {
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const { data } = usePlans();
// Hide for free plan users
if (!data || data.currentPlan.id === 'free') {
return null;
}
// Hide for trial users who haven't scheduled a subscription yet
if (trialStatus?.isTrialing && !trialStatus.hasScheduledSub) {
return null;
}
const onClick = async () => {
setLoading(true);
setErr(null);
try {
const { data, error } = await supabase.functions.invoke('manage-billing', {
body: {
name: 'Functions',
return_url: returnUrl},
})
if (error) throw error;
if (!data || 'error' in data) throw new Error((data as any)?.error ?? 'No portal URL');
window.location.href = (data as any).url;
} catch (e: any) {
setErr(e.message ?? 'Could not open billing portal');
} finally {
setLoading(false);
}
};
return (
<div>
<Button onClick={onClick} disabled={loading} className="px-4 py-2 rounded bg-black text-white">
{loading ? 'Opening…' : children}
</Button>
{err && <div className="mt-2 text-red-600">{err}</div>}
</div>
);
}
@@ -0,0 +1,30 @@
import React from 'react';
interface PrivateContentProps extends React.HTMLAttributes<HTMLSpanElement> {
children: React.ReactNode;
}
/**
* SaaS override of the OSS PrivateContent wrapper.
* Adds both the PostHog no-capture class and the Userback opt-out class
* while keeping the same API and layout behavior (display: contents).
*/
export const PrivateContent: React.FC<PrivateContentProps> = ({
children,
className = '',
style,
...props
}) => {
const baseClass = 'ph-no-capture userback-block';
const combinedClassName = className ? `${baseClass} ${className}` : baseClass;
const combinedStyle = {
display: 'contents' as const,
...style,
};
return (
<span className={combinedClassName} style={combinedStyle} {...props}>
{children}
</span>
);
};
@@ -0,0 +1,231 @@
import React, { useState, useEffect } from 'react';
import { Modal, Button, Text, Alert, Loader, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
import { supabase } from '@app/auth/supabase';
import { Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_DEFAULT_KEY);
export type PurchaseType = 'subscription' | 'credits';
export type CreditsPack = 'xsmall' | 'small' | 'medium' | 'large' | null;
export type PlanID = 'pro' | null;
interface StripeCheckoutProps {
opened: boolean;
onClose: () => void;
// Saas-specific props
planId?: PlanID;
purchaseType?: PurchaseType;
creditsPack?: CreditsPack;
planName?: string;
planPrice?: number;
currency?: string;
isTrialConversion?: boolean;
// Proprietary-specific props (for compatibility)
planGroup?: any;
minimumSeats?: number;
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
hostedCheckoutSuccess?: {
isUpgrade: boolean;
licenseKey?: string;
} | null;
// Common props
onSuccess?: (sessionId: string) => void;
onError?: (error: string) => void;
}
type CheckoutState = {
status: 'idle' | 'loading' | 'ready' | 'success' | 'error';
clientSecret?: string;
error?: string;
sessionParams?: {
purchaseType: PurchaseType;
planId: PlanID;
creditsPack: CreditsPack;
};
};
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
opened,
onClose,
planId,
purchaseType,
creditsPack,
planName,
isTrialConversion,
onSuccess,
onError
}) => {
const { t } = useTranslation();
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
const createCheckoutSession = async () => {
try {
setState({ status: 'loading' });
const { data, error } = await supabase.functions.invoke('create-checkout', {
body: {
purchase_type: purchaseType,
ui_mode: 'embedded',
plan: planId,
credits_pack: creditsPack,
callback_base_url: window.location.origin,
trial_conversion: isTrialConversion || false
}
});
if (error) {
throw new Error(error.message || 'Failed to create checkout session');
}
if (!data) {
throw new Error('No data received from server');
}
const jsonData = typeof data === 'string' ? JSON.parse(data) : data;
if (!jsonData?.clientSecret) {
throw new Error('No client secret received from server');
}
setState({
status: 'ready',
clientSecret: jsonData.clientSecret,
sessionParams: {
purchaseType: purchaseType!,
planId: planId!,
creditsPack: creditsPack!
}
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create checkout session';
setState({
status: 'error',
error: errorMessage
});
onError?.(errorMessage);
}
};
const handlePaymentComplete = () => {
setState({ status: 'success' });
// Call success callback immediately - parent will handle timing
onSuccess?.('');
// Note: Parent (Plan.tsx) now handles the delay and modal closing
};
const handleClose = () => {
// Reset state to idle to clean up the session
setState({ status: 'idle', clientSecret: undefined, error: undefined, sessionParams: undefined });
onClose();
};
// Initialize checkout when modal opens or parameters change
useEffect(() => {
if (opened) {
// Check if we need a new session (first time or parameters changed)
const needsNewSession =
state.status === 'idle' ||
!state.sessionParams ||
state.sessionParams.purchaseType !== purchaseType ||
state.sessionParams.planId !== planId ||
state.sessionParams.creditsPack !== creditsPack;
if (needsNewSession) {
console.log('Creating new checkout session:', { purchaseType, planId, creditsPack });
createCheckoutSession();
}
} else if (!opened) {
// Clean up state when modal closes
setState({ status: 'idle', clientSecret: undefined, error: undefined, sessionParams: undefined });
}
}, [opened, purchaseType, planId, creditsPack]);
const renderContent = () => {
switch (state.status) {
case 'loading':
return (
<div className="flex flex-col items-center justify-center py-8">
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('payment.preparing', 'Preparing your checkout...')}
</Text>
</div>
);
case 'ready':
if (!state.clientSecret) return null;
return (
<EmbeddedCheckoutProvider
key={state.clientSecret}
stripe={stripePromise}
options={{
clientSecret: state.clientSecret,
onComplete: handlePaymentComplete
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
case 'success':
return (
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>
<Stack gap="md">
<Text size="sm">
{t('payment.successMessage', 'Your plan has been upgraded successfully. You will receive a confirmation email shortly.')}
</Text>
<Text size="xs" c="dimmed">
{t('payment.autoClose', 'This window will close automatically...')}
</Text>
</Stack>
</Alert>
);
case 'error':
return (
<Alert color="red" title={t('payment.error', 'Payment Error')}>
<Stack gap="md">
<Text size="sm">{state.error}</Text>
<Button variant="outline" onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
</Stack>
</Alert>
);
default:
return null;
}
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<div>
<Text fw={600} size="lg">
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName })}
</Text>
</div>
}
size="xl"
centered
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
zIndex={Z_INDEX_OVER_SETTINGS_MODAL}
>
{renderContent()}
</Modal>
);
};
export default StripeCheckout;
export { StripeCheckout };
@@ -0,0 +1,178 @@
import { Modal, Stack, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
interface TrialExpiredModalProps {
opened: boolean;
onClose: () => void;
onSubscribe: () => void;
}
export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpiredModalProps) {
const { t } = useTranslation();
// Use CSS variables for theme colors
const amberColor = getComputedStyle(document.documentElement).getPropertyValue('--color-amber-500').trim() || '#f59e0b';
const redColor = getComputedStyle(document.documentElement).getPropertyValue('--color-red-500').trim() || '#ef4444';
const gradientStops: [string, string] = [amberColor, redColor];
const circles = [
{
position: 'bottom-left' as const,
size: 270, // 16.875rem
color: 'rgba(255, 255, 255, 0.25)',
opacity: 0.9,
amplitude: 24, // 1.5rem
duration: 4.5,
offsetX: 18, // 1.125rem
offsetY: 14, // 0.875rem
},
{
position: 'top-right' as const,
size: 300, // 18.75rem
color: 'rgba(255, 255, 255, 0.2)',
opacity: 0.9,
amplitude: 28, // 1.75rem
duration: 4.5,
delay: 0.5,
offsetX: 24, // 1.5rem
offsetY: 18, // 1.125rem
},
];
return (
<Modal
opened={opened}
onClose={() => {}} // Prevent closing by clicking outside or ESC
withCloseButton={false}
closeOnClickOutside={false}
closeOnEscape={false}
centered
size="lg"
radius="lg"
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0 },
content: {
overflow: 'hidden',
border: 'none',
background: 'var(--bg-surface)',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
},
}}
>
<Stack gap={0} className={styles.modalContent} style={{ height: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}>
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
<AnimatedSlideBackground
gradientStops={gradientStops}
circles={circles}
isActive
slideKey="trial-expired"
/>
<div className={styles.heroLogo}>
<div className={styles.heroLogoCircle}>
<DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />
</div>
</div>
</div>
<div
className={styles.modalBody}
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
WebkitOverflowScrolling: 'touch',
}}
>
<Stack gap={16}>
<div className={`${styles.title} ${styles.titleText}`}>
{t('plan.trial.expired', 'Your Trial Has Ended')}
</div>
<div className={styles.bodyText}>
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{t(
'plan.trial.expiredMessage',
'Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier.'
)}
</div>
</div>
<div className={styles.bodyText}>
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{t('plan.trial.freeTierLimitations', 'Free tier includes basic PDF tools with usage limits.')}
</div>
</div>
<div className={styles.buttonContainer}>
<style>{`
@media (max-width: 30rem) {
.trial-button-container {
justify-content: center !important;
}
.trial-modal-button {
flex: 1 1 100% !important;
}
}
.trial-modal-button-primary:hover {
background: linear-gradient(135deg, var(--color-amber-600), var(--color-red-600)) !important;
}
`}</style>
<div
className="trial-button-container"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
justifyContent: 'space-between',
}}
>
<Button
onClick={onClose}
variant="default"
size="sm"
className="trial-modal-button"
style={{
fontSize: '0.8125rem',
padding: '0.5rem 1rem',
height: 'auto',
minWidth: '8.125rem',
flex: '0 1 auto',
border: '0'
}}
>
{t('plan.trial.continueWithFree', 'Continue with Free')}
</Button>
<Button
onClick={onSubscribe}
size="md"
className="trial-modal-button trial-modal-button-primary"
style={{
background: 'linear-gradient(135deg, var(--color-amber-500), var(--color-red-500))',
color: '#FFFFFF',
fontSize: '0.9375rem',
fontWeight: 600,
padding: '0.75rem 1.5rem',
height: 'auto',
border: 'none',
minWidth: '10.625rem',
flex: '0 1 auto',
}}
>
{t('plan.trial.subscribeToPro', 'Subscribe to Pro')}
</Button>
</div>
</div>
</Stack>
</div>
</Stack>
</Modal>
);
}
@@ -0,0 +1,121 @@
import { useEffect, useState, useCallback } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import { useAuth } from '@app/auth/UseSession';
import { useTranslation } from 'react-i18next';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import StripeCheckout from '@app/components/shared/StripeCheckoutSaas';
import { BASE_PATH } from '@app/constants/app';
const SESSION_STORAGE_KEY = 'trialBannerDismissed';
export function TrialStatusBanner() {
const { setBanner } = useBanner();
const { t } = useTranslation();
const { trialStatus } = useAuth();
const [dismissed, setDismissed] = useState(() => {
return sessionStorage.getItem(SESSION_STORAGE_KEY) === 'true';
});
const [checkoutOpen, setCheckoutOpen] = useState(false);
// Only show banner during ACTIVE trial (not after expiration - modal handles that)
// Don't show if payment method already added (user has scheduled subscription)
const shouldShowBanner =
trialStatus &&
trialStatus.isTrialing && // Only show during active trial
trialStatus.daysRemaining > 0 && // Trial hasn't expired yet
!trialStatus.hasPaymentMethod &&
!trialStatus.hasScheduledSub &&
!dismissed;
if (trialStatus?.hasPaymentMethod || trialStatus?.hasScheduledSub) {
console.log('Subscription scheduled - hiding trial banner');
}
const handleOpenCheckout = useCallback(() => {
setCheckoutOpen(true);
}, []);
const handleDismiss = useCallback(() => {
setDismissed(true);
sessionStorage.setItem(SESSION_STORAGE_KEY, 'true');
}, []);
useEffect(() => {
if (!shouldShowBanner) {
setBanner(null);
return;
}
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString('en-GB', {
month: 'short',
day: 'numeric'
});
const message = t(
'plan.trial.message',
`Your trial ends in ${trialStatus.daysRemaining} day${trialStatus.daysRemaining !== 1 ? 's' : ''} (${trialEndDate}). Subscribe to continue Pro access.`,
{ days: trialStatus.daysRemaining, date: trialEndDate }
);
const logoIcon = (
<img
src={`${BASE_PATH}/modern-logo/logo512.png`}
alt="Stirling PDF"
style={{
width: '1.5rem',
height: '1.5rem',
objectFit: 'contain'
}}
/>
);
setBanner(
<InfoBanner
icon={logoIcon}
tone="info"
message={message}
buttonText={t('plan.trial.subscribe', 'Subscribe to Pro')}
buttonIcon="credit-card-rounded"
onButtonClick={handleOpenCheckout}
onDismiss={handleDismiss}
dismissible={true}
show={true}
background="var(--mantine-color-dark-7)"
borderColor="var(--mantine-color-dark-5)"
textColor="rgba(255, 255, 255, 0.95)"
iconColor="rgba(255, 255, 255, 0.95)"
buttonColor="gray"
buttonVariant="white"
buttonTextColor="var(--mantine-color-dark-9)"
closeIconColor="rgba(255, 255, 255, 0.7)"
/>
);
return () => {
setBanner(null);
};
}, [shouldShowBanner, trialStatus, setBanner, t, handleOpenCheckout, handleDismiss]);
const handleCheckoutSuccess = () => {
// Refresh to hide banner and show updated plan
window.location.reload();
};
return (
<>
{trialStatus && (
<StripeCheckout
opened={checkoutOpen}
onClose={() => setCheckoutOpen(false)}
purchaseType="subscription"
planId="pro"
creditsPack={null}
planName="Pro"
onSuccess={handleCheckoutSuccess}
onError={(error) => console.error('Checkout error:', error)}
isTrialConversion={true}
/>
)}
</>
);
}
@@ -0,0 +1,268 @@
import React, { useEffect, useMemo, useRef, useCallback, useId } from 'react';
import { Group, Loader, Text } from '@mantine/core';
import * as d3 from 'd3';
import { StackedBarChartProps, TooltipData, FractionData } from '@app/types/charts';
import { generateTooltipHTML } from '@app/components/shared/charts/stackedBarChart/StackedBarTooltip';
import { detectTheme, getChartThemeVars } from '@app/components/shared/charts/utils/themeUtils';
import { createTooltipPositioner } from '@app/components/shared/charts/utils/tooltipUtils';
import { createRoundedRectPath, createScale } from '@app/components/shared/charts/utils/d3Utils';
export default function StackedBarChart({
fractions,
width = 640,
height = 22,
showLegend = true,
className = '',
tooltipPosition = 'top',
loading = false,
animate = true,
animationDurationMs = 900,
ariaLabel,
}: StackedBarChartProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const hasAnimatedRef = useRef(false);
const reactId = useId();
const clipId = useMemo(() => `clip-${reactId}`, [reactId]);
// Memoize theme detection to avoid recalculation
const theme = useMemo(() => detectTheme(), []);
const themeVars = useMemo(() => getChartThemeVars(theme.isDark), [theme.isDark]);
// Memoize tooltip positioner
const tooltipPositioner = useMemo(() => createTooltipPositioner(tooltipPosition), [tooltipPosition]);
const positionTooltip = useCallback((event: MouseEvent) => {
const tooltip = tooltipRef.current;
if (!tooltip) return;
tooltipPositioner.positionTooltip(event, tooltip, containerRef.current!);
}, [tooltipPositioner]);
const setTooltipContent = useCallback((labelHtml: string) => {
const tooltip = tooltipRef.current;
if (!tooltip) return;
tooltip.innerHTML = labelHtml;
tooltip.style.background = themeVars.background;
tooltip.style.color = themeVars.textPrimary;
tooltip.style.border = themeVars.border;
tooltip.style.boxShadow = themeVars.boxShadow;
tooltip.style.padding = '8px 10px';
tooltip.style.fontSize = '12px';
tooltip.style.lineHeight = '1.25';
tooltip.style.borderRadius = '8px';
}, [themeVars]);
const hideTooltip = useCallback(() => {
const tooltip = tooltipRef.current;
if (!tooltip) return;
tooltip.style.opacity = '0';
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.innerHTML = '';
// Calculate total capacity (sum of all denominators)
const totalCapacity = fractions.reduce((sum: number, fraction: FractionData) => sum + fraction.denominator, 0);
if (totalCapacity === 0 && !loading) return;
// Create data for the bar segments
const data = fractions.map((fraction: FractionData) => ({
...fraction,
value: fraction.numerator,
remaining: fraction.denominator - fraction.numerator
}));
const radius = 8;
const svg = d3
.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
.attr('role', ariaLabel ? 'img' : null)
.attr('aria-label', ariaLabel || null);
const x = createScale([0, totalCapacity], [0, width]);
let cursor = 0;
const g = svg.append('g');
// Skip drawing the bar visuals entirely when loading to avoid gray bar under spinner
if (!loading) {
// Create a single rounded rectangle for the entire bar background
const totalBarWidth = x(totalCapacity);
g.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', totalBarWidth)
.attr('height', height)
.attr('rx', radius)
.attr('ry', radius)
.attr('fill', themeVars.inactive)
.attr('stroke', themeVars.cardBorder);
// Define a clipPath that will reveal the used portion from left to right
const defs = svg.append('defs');
const clipRect = defs
.append('clipPath')
.attr('id', clipId)
.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', 0)
.attr('height', height)
.attr('rx', radius)
.attr('ry', radius);
// Group to hold the used segments and apply clip-path
const usedGroup = svg.append('g').attr('clip-path', `url(#${clipId})`);
// Render used segments on top of the background
data.forEach((fraction: typeof data[number], index: number) => {
if (fraction.value <= 0) return;
const segWidth = x(fraction.value);
const xPos = cursor;
cursor += segWidth;
const isFirst = index === 0;
const isLast = index === data.length - 1; // Last segment regardless of remaining
if (isFirst && isLast) {
// Single segment: fully rounded
usedGroup.append('rect')
.attr('x', xPos)
.attr('y', 0)
.attr('width', segWidth)
.attr('height', height)
.attr('rx', radius)
.attr('ry', radius)
.attr('fill', fraction.color);
} else if (isFirst) {
// First segment: rounded on left side only
const path = createRoundedRectPath(xPos, 0, segWidth, height, radius, {
topLeft: true,
topRight: false,
bottomLeft: true,
bottomRight: false
});
usedGroup.append('path')
.attr('d', path)
.attr('fill', fraction.color);
} else if (isLast) {
// Last segment: rounded on right side only
const path = createRoundedRectPath(xPos, 0, segWidth, height, radius, {
topLeft: false,
topRight: true,
bottomLeft: false,
bottomRight: true
});
usedGroup.append('path')
.attr('d', path)
.attr('fill', fraction.color);
} else {
// Middle segments: no rounded edges
usedGroup.append('rect')
.attr('x', xPos)
.attr('y', 0)
.attr('width', segWidth)
.attr('height', height)
.attr('fill', fraction.color);
}
});
// Add a transparent overlay for hover events on the entire bar (outside clip path)
svg.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', totalBarWidth)
.attr('height', height)
.attr('rx', radius)
.attr('ry', radius)
.attr('fill', 'transparent')
.style('pointer-events', 'all')
.on('mouseenter', (event: MouseEvent) => {
const tooltipData: TooltipData = { fractions: data, isDark: theme.isDark };
const html = generateTooltipHTML(tooltipData);
setTooltipContent(html);
const tooltip = tooltipRef.current;
if (tooltip) tooltip.style.opacity = '1';
positionTooltip(event as unknown as MouseEvent);
})
.on('mousemove', (event: MouseEvent) => positionTooltip(event as unknown as MouseEvent))
.on('mouseleave', hideTooltip);
// Animate reveal of used segments (only on first load, not on re-renders)
const totalUsed = data.reduce((sum: number, f: typeof data[number]) => sum + f.value, 0);
const revealTo = x(totalUsed);
if (animate && !hasAnimatedRef.current) {
clipRect.transition().duration(animationDurationMs).attr('width', revealTo);
hasAnimatedRef.current = true;
} else {
clipRect.attr('width', revealTo);
}
}
return () => { container.innerHTML = ''; };
}, [fractions, width, height, tooltipPosition, loading, animate, animationDurationMs, clipId, themeVars, setTooltipContent, hideTooltip, positionTooltip]);
return (
<div className={className}>
<div style={{ position: 'relative' }}>
<div ref={containerRef} />
<div
ref={tooltipRef}
style={{
position: 'absolute',
left: 0,
top: 0,
pointerEvents: 'none',
opacity: 0,
transition: 'opacity 120ms ease',
zIndex: 1000
}}
/>
{loading && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Loader size="sm" color="blue" />
</div>
)}
</div>
{showLegend && (
<Group gap="lg" mt="sm">
{fractions.map((fraction: FractionData, index: number) => (
<Group key={index} gap={6}>
<span
style={{
width: 10,
height: 10,
background: fraction.color,
display: 'inline-block',
borderRadius: 2
}}
/>
<Text size="sm">{fraction.name}</Text>
</Group>
))}
<Group gap={6}>
<span
style={{
width: 10,
height: 10,
background: themeVars.inactive,
display: 'inline-block',
borderRadius: 2,
outline: `1px solid ${themeVars.cardBorder}`
}}
/>
<Text size="sm">Remaining</Text>
</Group>
</Group>
)}
</div>
);
}
@@ -0,0 +1,43 @@
import React from 'react';
import { TooltipData } from '@app/types/charts';
interface StackedBarTooltipProps {
data: TooltipData;
}
export default function StackedBarTooltip({ data }: StackedBarTooltipProps) {
const { fractions } = data;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', whiteSpace: 'nowrap' }}>
{fractions.map((f, index) => (
<div key={index} style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<span style={{
display: 'inline-block',
width: '10px',
height: '10px',
background: f.color,
borderRadius: '2px'
}}></span>
<span>
<strong>{f.name}</strong> {f.numeratorLabel}: {f.numerator} · {f.denominatorLabel}: {f.denominator - f.numerator}
</span>
</div>
))}
</div>
);
}
export function generateTooltipHTML(data: TooltipData): string {
const { fractions } = data;
return `
<div style="display:flex;flex-direction:column;gap:6px;white-space:nowrap;">
${fractions.map(f => `
<div style="display:flex;gap:8px;align-items:center;">
<span style="display:inline-block;width:10px;height:10px;background:${f.color};border-radius:2px"></span>
<span><strong>${f.name}</strong> — ${f.numeratorLabel}: ${f.numerator} · ${f.denominatorLabel}: ${Math.max(0, f.denominator - f.numerator)}</span>
</div>
`).join('')}
</div>`;
}
@@ -0,0 +1,174 @@
/**
* Reusable D3 utility functions for chart creation
*/
import * as d3 from 'd3';
export interface ChartDimensions {
width: number;
height: number;
margin?: {
top?: number;
right?: number;
bottom?: number;
left?: number;
};
}
export interface AnimationConfig {
duration: number;
easing?: (t: number) => number;
}
/**
* Creates a basic SVG element with proper attributes
* @param container The container element
* @param dimensions Chart dimensions
* @param className Optional CSS class name
* @returns The created SVG selection
*/
export function createSVG(
container: HTMLElement,
dimensions: ChartDimensions,
className?: string
): d3.Selection<SVGSVGElement, unknown, null, undefined> {
const svg = d3
.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', dimensions.height)
.attr('viewBox', `0 0 ${dimensions.width} ${dimensions.height}`)
.attr('class', className || '');
return svg;
}
/**
* Creates a clip path for revealing content with animation
* @param svg The SVG selection
* @param clipId Unique ID for the clip path
* @param dimensions Chart dimensions
* @returns The clip rect selection
*/
export function createClipPath(
svg: d3.Selection<SVGSVGElement, unknown, null, undefined>,
clipId: string,
dimensions: ChartDimensions
): d3.Selection<SVGRectElement, unknown, null, undefined> {
const defs = svg.append('defs');
const clipRect = defs
.append('clipPath')
.attr('id', clipId)
.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', 0)
.attr('height', dimensions.height);
return clipRect;
}
/**
* Animates a clip path to reveal content
* @param clipRect The clip rect selection
* @param targetWidth The target width to animate to
* @param config Animation configuration
*/
export function animateClipReveal(
clipRect: d3.Selection<SVGRectElement, unknown, null, undefined>,
targetWidth: number,
config: AnimationConfig
): void {
clipRect
.transition()
.duration(config.duration)
.ease(config.easing || d3.easeCubicInOut)
.attr('width', targetWidth);
}
/**
* Creates a rounded rectangle path for D3
* @param x X position
* @param y Y position
* @param width Width
* @param height Height
* @param radius Corner radius
* @param corners Which corners to round (default: all)
* @returns SVG path string
*/
export function createRoundedRectPath(
x: number,
y: number,
width: number,
height: number,
radius: number,
corners: { topLeft?: boolean; topRight?: boolean; bottomLeft?: boolean; bottomRight?: boolean } = {}
): string {
const { topLeft = true, topRight = true, bottomLeft = true, bottomRight = true } = corners;
if (width <= 0 || height <= 0) return '';
const topLeftRadius = topLeft ? radius : 0;
const topRightRadius = topRight ? radius : 0;
const bottomRightRadius = bottomRight ? radius : 0;
const bottomLeftRadius = bottomLeft ? radius : 0;
let path = `M ${x + topLeftRadius} ${y}`;
if (topRight) {
path += ` L ${x + width - topRightRadius} ${y}`;
path += ` A ${topRightRadius} ${topRightRadius} 0 0 1 ${x + width} ${y + topRightRadius}`;
} else {
path += ` L ${x + width} ${y}`;
}
if (bottomRight) {
path += ` L ${x + width} ${y + height - bottomRightRadius}`;
path += ` A ${bottomRightRadius} ${bottomRightRadius} 0 0 1 ${x + width - bottomRightRadius} ${y + height}`;
} else {
path += ` L ${x + width} ${y + height}`;
}
if (bottomLeft) {
path += ` L ${x + bottomLeftRadius} ${y + height}`;
path += ` A ${bottomLeftRadius} ${bottomLeftRadius} 0 0 1 ${x} ${y + height - bottomLeftRadius}`;
} else {
path += ` L ${x} ${y + height}`;
}
if (topLeft) {
path += ` L ${x} ${y + topLeftRadius}`;
path += ` A ${topLeftRadius} ${topLeftRadius} 0 0 1 ${x + topLeftRadius} ${y}`;
} else {
path += ` L ${x} ${y}`;
}
return path + ' Z';
}
/**
* Creates a reusable scale factory
* @param domain Domain values
* @param range Range values
* @returns D3 scale function
*/
export function createScale(domain: [number, number], range: [number, number]) {
return d3.scaleLinear().domain(domain).range(range);
}
/**
* Debounces a function call
* @param func The function to debounce
* @param wait The wait time in milliseconds
* @returns Debounced function
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
@@ -0,0 +1,63 @@
/**
* Utility functions for theme detection and styling in D3 charts
*/
export interface ThemeInfo {
isDark: boolean;
schemeAttr: string | null | undefined;
prefersDark: boolean;
}
/**
* Detects the current theme from various sources
* @returns ThemeInfo object with theme detection results
*/
export function detectTheme(): ThemeInfo {
const rootEl = typeof document !== 'undefined' ? document.documentElement : null;
const schemeAttr = rootEl?.getAttribute('data-mantine-color-scheme');
const prefersDark = typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = schemeAttr ? schemeAttr === 'dark' : prefersDark;
return {
isDark,
schemeAttr,
prefersDark
};
}
/**
* Gets CSS custom properties for chart styling
* @param isDark Whether the theme is dark
* @returns Object with CSS custom property values
*/
export function getChartThemeVars(isDark: boolean) {
return {
background: 'var(--bg-surface)',
textPrimary: 'var(--text-primary)',
border: isDark ? '1px solid var(--border-subtle)' : '1px solid transparent',
boxShadow: isDark ? 'none' : 'var(--shadow-md)',
inactive: 'var(--usage-inactive)',
cardBorder: 'var(--api-keys-card-border)'
};
}
/**
* Applies consistent tooltip styling
* @param tooltipElement The tooltip DOM element
* @param isDark Whether the theme is dark
*/
export function applyTooltipStyles(tooltipElement: HTMLElement, isDark: boolean) {
const themeVars = getChartThemeVars(isDark);
tooltipElement.style.background = themeVars.background;
tooltipElement.style.color = themeVars.textPrimary;
tooltipElement.style.border = themeVars.border;
tooltipElement.style.boxShadow = themeVars.boxShadow;
tooltipElement.style.padding = '8px 10px';
tooltipElement.style.fontSize = '12px';
tooltipElement.style.lineHeight = '1.25';
tooltipElement.style.borderRadius = '8px';
}
@@ -0,0 +1,74 @@
/**
* Utility functions for tooltip positioning and management in D3 charts
*/
export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
export interface TooltipPositioner {
positionTooltip: (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => void;
hideTooltip: (tooltip: HTMLElement) => void;
}
/**
* Creates a tooltip positioner for the specified position
* @param position The tooltip position preference
* @returns TooltipPositioner object with positioning functions
*/
export function createTooltipPositioner(position: TooltipPosition): TooltipPositioner {
const positionTooltip = (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => {
const bounds = container.getBoundingClientRect();
const offsetX = event.clientX - bounds.left;
const offsetY = event.clientY - bounds.top;
// Get tooltip dimensions after content is set
const tooltipHeight = tooltip.offsetHeight;
const tooltipWidth = tooltip.offsetWidth;
const gap = 16; // 1rem gap
// Position tooltip based on the specified position
switch (position) {
case 'top':
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, Math.max(10, offsetX - tooltipWidth / 2))}px`;
tooltip.style.top = `${offsetY - tooltipHeight - gap}px`;
break;
case 'bottom':
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, Math.max(10, offsetX - tooltipWidth / 2))}px`;
tooltip.style.top = `${offsetY + gap}px`;
break;
case 'left':
tooltip.style.left = `${Math.max(10, offsetX - tooltipWidth - gap)}px`;
tooltip.style.top = `${offsetY - tooltipHeight / 2}px`;
break;
case 'right':
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, offsetX + gap)}px`;
tooltip.style.top = `${offsetY - tooltipHeight / 2}px`;
break;
}
};
const hideTooltip = (tooltip: HTMLElement) => {
tooltip.style.opacity = '0';
};
return { positionTooltip, hideTooltip };
}
/**
* Creates a reusable tooltip element with consistent styling
* @param container The container element to append the tooltip to
* @returns The created tooltip element
*/
export function createTooltipElement(container: HTMLElement): HTMLElement {
const tooltip = document.createElement('div');
tooltip.style.position = 'absolute';
tooltip.style.left = '0';
tooltip.style.top = '0';
tooltip.style.pointerEvents = 'none';
tooltip.style.opacity = '0';
tooltip.style.transition = 'opacity 120ms ease';
tooltip.style.zIndex = '1000';
container.appendChild(tooltip);
return tooltip;
}
@@ -0,0 +1,187 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Modal, Button, Stack, Slider, Alert, Text, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import Cropper from 'react-easy-crop';
import { getCroppedImage, type Area } from '@app/utils/cropImage';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface ProfilePictureCropperProps {
file: File | null;
opened: boolean;
onClose: () => void;
onCropComplete: (croppedBlob: Blob) => void;
}
export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
file,
opened,
onClose,
onCropComplete,
}) => {
const { t } = useTranslation();
// State management
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load image when file changes
useEffect(() => {
if (!file) {
setImageSrc(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
setImageSrc(reader.result as string);
setError(null);
};
reader.onerror = () => {
setError(t('config.account.profilePicture.cropper.invalidImage', 'Invalid image file. Please select a valid PNG, JPG, or WebP file.'));
};
reader.readAsDataURL(file);
// Cleanup
return () => {
if (imageSrc) {
URL.revokeObjectURL(imageSrc);
}
};
}, [file, t]);
// Reset state when modal closes
useEffect(() => {
if (!opened) {
setCrop({ x: 0, y: 0 });
setZoom(1);
setCroppedAreaPixels(null);
setProcessing(false);
setError(null);
}
}, [opened]);
// Called when crop area changes
const onCropChange = useCallback((newCrop: { x: number; y: number }) => {
setCrop(newCrop);
}, []);
// Called when zoom changes
const onZoomChange = useCallback((newZoom: number) => {
setZoom(newZoom);
}, []);
// Called when crop is complete (stores the crop area in pixels)
const onCropCompleteCallback = useCallback(
(_croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels);
},
[]
);
// Process and save the cropped image
const handleSave = async () => {
if (!imageSrc || !croppedAreaPixels) {
return;
}
setProcessing(true);
setError(null);
try {
// Crop the image
const croppedBlob = await getCroppedImage(imageSrc, croppedAreaPixels);
// Validate size (2MB limit)
const maxSize = 2 * 1024 * 1024; // 2MB in bytes
if (croppedBlob.size > maxSize) {
setError(
t(
'config.account.profilePicture.cropper.sizeErrorAfterCrop',
'Cropped image is too large. Please zoom out or crop a smaller area.'
)
);
setProcessing(false);
return;
}
// Call parent callback with cropped blob
onCropComplete(croppedBlob);
onClose();
} catch (err) {
console.error('Error cropping image:', err);
setError(
t(
'config.account.profilePicture.cropper.cropError',
'Failed to crop image. Please try again.'
)
);
} finally {
setProcessing(false);
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t('config.account.profilePicture.cropper.title', 'Crop Profile Picture')}
size="lg"
centered
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
{error && (
<Alert color="red" title="Error">
{error}
</Alert>
)}
{/* Cropper area */}
<Box style={{ position: 'relative', width: '100%', height: 400 }}>
{imageSrc && (
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
onCropChange={onCropChange}
onZoomChange={onZoomChange}
onCropComplete={onCropCompleteCallback}
/>
)}
</Box>
{/* Zoom slider */}
<Stack gap={4}>
<Text size="sm" fw={500}>
{t('config.account.profilePicture.cropper.zoom', 'Zoom')}
</Text>
<Slider
value={zoom}
min={1}
max={3}
step={0.1}
onChange={setZoom}
disabled={processing}
/>
</Stack>
{/* Action buttons */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<Button variant="subtle" onClick={onClose} disabled={processing}>
{t('common.cancel', 'Cancel')}
</Button>
<Button onClick={handleSave} loading={processing}>
{processing
? t('config.account.profilePicture.cropper.processing', 'Processing crop...')
: t('config.account.profilePicture.cropper.save', 'Save Cropped Image')}
</Button>
</div>
</Stack>
</Modal>
);
};
@@ -0,0 +1,114 @@
import React, { useState } from "react";
import { Anchor, Group, Stack, Text, Button, Paper } from "@mantine/core";
import UsageSection from "@app/components/shared/config/configSections/apiKeys/UsageSection";
import ApiKeySection from "@app/components/shared/config/configSections/apiKeys/ApiKeySection";
import RefreshModal from "@app/components/shared/config/configSections/apiKeys/RefreshModal";
import { useCredits } from "@app/components/shared/config/configSections/apiKeys/hooks/useCredits";
import useApiKey from "@app/components/shared/config/configSections/apiKeys/hooks/useApiKey";
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { isUserAnonymous } from "@app/auth/supabase";
export default function ApiKeys() {
const [copied, setCopied] = useState<string | null>(null);
const [showRefreshModal, setShowRefreshModal] = useState(false);
const { t } = useTranslation();
const { user } = useAuth();
const isAnonymous = Boolean(user && isUserAnonymous(user));
const { data: credits, isLoading: creditsLoading } = useCredits();
const { apiKey, isLoading: apiKeyLoading, refresh, isRefreshing, error: apiKeyError, refetch, hasAttempted } = useApiKey();
const copy = async (text: string, tag: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(tag);
setTimeout(() => setCopied(null), 1600);
} catch (e) {
// noop you can surface a notification here
console.error(e);
}
};
const refreshKeys = async () => {
try {
await refresh();
} finally {
setShowRefreshModal(false);
}
};
const goToAccount = () => {
window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: 'overview' } }));
};
const showUsage = Boolean(credits);
return (
<Stack gap={20} p={0}>
{showUsage && (
<UsageSection
apiUsage={credits!}
obscured={Boolean(!apiKey && hasAttempted && !isAnonymous)}
overlayMessage={t('config.apiKeys.overlayMessage', 'Generate a key to see credits and available credits')}
loading={creditsLoading}
/>
)}
{!isAnonymous && apiKeyError && (
<Text size="sm" c="red.5">
{t('config.apiKeys.generateError', "We couldn't generate your API key.")} {" "}
<Anchor component="button" underline="always" onClick={refetch} c="red.4">
{t('common.retry', 'Retry')}
</Anchor>
</Text>
)}
{isAnonymous ? (
<Paper radius="md" p={18} style={{ background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
<Stack gap={10}>
<Text fw={500}>{t('config.apiKeys.label', 'API Key')}</Text>
<Group justify="space-between" wrap="nowrap" align="center" style={{ gap: '1rem' }}>
<Text size="sm" c="dimmed" style={{ flex: 1 }}>
{t('config.apiKeys.guestInfo', 'Guest users do not receive API keys. Create an account to get an API key you can use in your applications.')}
</Text>
<Button size="sm" onClick={goToAccount}>
{t('config.apiKeys.goToAccount', 'Go to Account')}
</Button>
</Group>
</Stack>
</Paper>
) : (
apiKeyLoading ? (
<>
<Text size="sm" c="dimmed" style={{ marginBottom: 8 }}>
{t('config.apiKeys.description', "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.")}
</Text>
<div style={{ padding: 18, borderRadius: 12, background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
<Group align="center" gap={12} wrap="nowrap">
<SkeletonLoader type="block" width="100%" height={36} />
<SkeletonLoader type="block" width={76} height={32} />
<SkeletonLoader type="block" width={92} height={32} />
</Group>
</div>
</>
) : (
<ApiKeySection
publicKey={apiKey ?? ""}
copied={copied}
onCopy={copy}
onRefresh={() => setShowRefreshModal(true)}
disabled={isRefreshing}
/>
)
)}
<RefreshModal
opened={showRefreshModal}
onClose={() => setShowRefreshModal(false)}
onConfirm={refreshKeys}
/>
</Stack>
);
}
@@ -0,0 +1,515 @@
import React, { useState } from 'react';
import { Alert, Avatar, Button, Divider, FileButton, Group, Image, LoadingOverlay, PasswordInput, Text, TextInput, Modal } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { isUserAnonymous, linkEmailIdentity, linkOAuthIdentity, supabase } from '@app/auth/supabase';
import { BASE_PATH } from '@app/constants/app';
import { oauthProviders } from '@app/constants/authProviders';
import { Tooltip } from '@app/components/shared/Tooltip';
import { absoluteWithBasePath } from '@app/constants/app';
import { synchronizeUserUpgrade } from '@app/services/userService';
import { ProfilePictureCropper } from '@app/components/shared/config/ProfilePictureCropper';
import { updateProfilePictureMetadata } from '@app/services/avatarSyncService';
import { deleteCurrentAccount } from '@app/services/accountDeletion';
import { alert as showToast } from '@app/components/toast';
interface OverviewProps {
onLogoutClick: () => void;
}
const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
const { t } = useTranslation();
const { user, refreshSession, signOut, profilePictureUrl, profilePictureMetadata, refreshProfilePicture, refreshProfilePictureMetadata } = useAuth();
const PROFILE_BUCKET = 'profile-pictures';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [upgradeError, setUpgradeError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [profileUploading, setProfileUploading] = useState(false);
const [profileError, setProfileError] = useState<string | null>(null);
const [cropperFile, setCropperFile] = useState<File | null>(null);
const [cropperOpen, setCropperOpen] = useState(false);
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [confirmEmail, setConfirmEmail] = useState('');
const isAnonymous = Boolean(user && isUserAnonymous(user));
const isOAuthPicture = profilePictureMetadata?.source === 'oauth';
const provider = profilePictureMetadata?.provider;
const profilePath = user ? `${user.id}/avatar` : null;
const profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || 'U';
const handleProfileUpload = async (file: File | null) => {
if (!file || !user || !profilePath) {
return;
}
if (file.size > 2 * 1024 * 1024) {
setProfileError(t('config.account.profilePicture.sizeError', 'Please select an image smaller than 2MB.'));
return;
}
// Open cropper instead of uploading directly
setProfileError(null);
setCropperFile(file);
setCropperOpen(true);
};
const handleCropComplete = async (croppedBlob: Blob) => {
if (!user || !profilePath) {
return;
}
// Validate cropped size (2MB limit)
if (croppedBlob.size > 2 * 1024 * 1024) {
setProfileError(t('config.account.profilePicture.sizeError', 'Please select an image smaller than 2MB.'));
setCropperOpen(false);
setCropperFile(null);
return;
}
setProfileUploading(true);
setProfileError(null);
const { error } = await supabase
.storage
.from(PROFILE_BUCKET)
.upload(profilePath, croppedBlob, {
upsert: true,
cacheControl: '3600',
contentType: 'image/png'
});
if (error) {
setProfileError(error.message || 'Failed to upload profile picture');
} else {
// Mark as manual upload in metadata
await updateProfilePictureMetadata(user.id, {
source: 'upload',
provider: null,
});
await refreshProfilePictureMetadata();
await refreshProfilePicture();
}
setProfileUploading(false);
setCropperOpen(false);
setCropperFile(null);
};
const handleProfileRemove = async () => {
if (!user || !profilePath) {
return;
}
setProfileUploading(true);
setProfileError(null);
const { error } = await supabase
.storage
.from(PROFILE_BUCKET)
.remove([profilePath]);
if (error) {
setProfileError(error.message || 'Failed to remove profile picture');
} else {
// Clear metadata when removing picture
await updateProfilePictureMetadata(user.id, {
source: 'upload',
provider: null,
});
await refreshProfilePictureMetadata();
await refreshProfilePicture();
}
setProfileUploading(false);
};
const handleUseCustomPicture = async () => {
if (!user) {
return;
}
setProfileUploading(true);
setProfileError(null);
try {
// Update metadata to allow manual uploads
await updateProfilePictureMetadata(user.id, {
source: 'upload',
provider: null,
});
await refreshProfilePictureMetadata();
setSuccess(t('config.account.profilePicture.switchedToCustom', 'Switched to custom picture. You can now upload your own.'));
// Clear success message after 3 seconds
setTimeout(() => setSuccess(null), 3000);
} catch (error: any) {
setProfileError(error.message || 'Failed to switch to custom picture');
} finally {
setProfileUploading(false);
}
};
const handleEmailUpgrade = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) {
setUpgradeError('Email is required');
return;
}
try {
setIsLoading(true);
setUpgradeError(null);
setSuccess(null);
// First, upgrade the account in Supabase
await linkEmailIdentity(email.trim(), password || undefined);
// Synchronize with backend database (using "email" as auth method for email/password)
await synchronizeUserUpgrade('email');
// Refresh the session to reflect changes
await refreshSession();
setSuccess('Account upgraded successfully! You can now sign in with your email.');
setEmail('');
setPassword('');
} catch (err: any) {
setUpgradeError(err?.message || 'Failed to upgrade account');
} finally {
setIsLoading(false);
}
};
const handleOAuthUpgrade = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
try {
setIsLoading(true);
setUpgradeError(null);
setSuccess(null);
// Store provider info for post-redirect handling
sessionStorage.setItem('pendingUpgrade', 'true');
sessionStorage.setItem('upgradeProvider', provider);
// Redirect back to homepage after OAuth completes
// The UseSession hook will handle the pendingUpgrade synchronization
const redirectUrl = absoluteWithBasePath('/auth/callback?next=/');
const result = await linkOAuthIdentity(provider, redirectUrl);
if (result.data?.url) {
window.location.href = result.data.url;
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : `Failed to upgrade account with ${provider}`;
setUpgradeError(errorMessage);
setIsLoading(false);
sessionStorage.removeItem('pendingUpgrade');
sessionStorage.removeItem('upgradeProvider');
}
};
const handleDeleteAccount = async () => {
if (isAnonymous) return;
try {
setIsDeletingAccount(true);
await deleteCurrentAccount();
setDeleteModalOpen(false);
setConfirmEmail('');
await signOut();
window.location.href = absoluteWithBasePath('/login');
} catch (err) {
const fallbackMessage = t('config.account.overview.deleteFailed', 'Failed to delete account.');
const message = err instanceof Error ? err.message : fallbackMessage;
console.error('[Overview] Delete account failed:', err);
showToast({
alertType: 'error',
title: t('config.account.overview.deleteFailedTitle', 'Unable to delete account'),
body: message,
expandable: true,
location: 'top-right',
durationMs: 7000
});
} finally {
setIsDeletingAccount(false);
}
};
const closeDeleteModal = () => {
setDeleteModalOpen(false);
setConfirmEmail('');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}>
<LoadingOverlay visible={isLoading || isDeletingAccount} />
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('config.account.overview.title', 'Account Settings')}
</h3>
<p style={{ margin: '0.25rem 0 0 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{isAnonymous
? t('config.account.overview.guestDescription', 'You are signed in as a guest. Consider upgrading your account below.')
: t('config.account.overview.manageAccountPreferences', 'Manage your account preferences')
}
</p>
{user?.email && (
<p style={{ margin: '0.25rem 0 0 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.75rem' }}>
{t('config.account.overview.signedInAs', 'Signed in as')}: {user.email}
</p>
)}
</div>
<Button color="red" variant="filled" onClick={onLogoutClick}>
{t('logOut', 'Log out')}
</Button>
</div>
</div>
<Divider />
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('config.account.profilePicture.title', 'Profile picture')}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('config.account.profilePicture.description', 'Upload an image to personalize your account.')}
</p>
{profileError && (
<Alert color="red" mb="md">
{profileError}
</Alert>
)}
{success && (
<Alert color="green" mb="md">
{success}
</Alert>
)}
{isOAuthPicture ? (
<Group align="center" gap="md">
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
{profileInitial}
</Avatar>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<Text size="sm" c="dimmed">
{t('config.account.profilePicture.usingProvider', 'Using {{provider}} profile picture', {
provider: provider ? provider.charAt(0).toUpperCase() + provider.slice(1) : 'OAuth'
})}
</Text>
<Button variant="outline" onClick={handleUseCustomPicture} disabled={profileUploading}>
{t('config.account.profilePicture.useCustom', 'Use custom picture')}
</Button>
</div>
</Group>
) : (
<Group align="center" gap="md">
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
{profileInitial}
</Avatar>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<Group gap="sm">
<FileButton
onChange={handleProfileUpload}
accept="image/png,image/jpeg,image/webp"
disabled={!user || profileUploading}
>
{(props) => (
<Button {...props} loading={profileUploading}>
{t('config.account.profilePicture.upload', 'Upload')}
</Button>
)}
</FileButton>
<Button
variant="outline"
onClick={handleProfileRemove}
disabled={!profilePictureUrl || profileUploading}
>
{t('config.account.profilePicture.remove', 'Remove')}
</Button>
</Group>
<Text size="xs" c="var(--mantine-color-dimmed)">
{t('config.account.profilePicture.help', 'PNG, JPG, or WebP up to 2MB.')}
</Text>
</div>
</Group>
)}
</div>
<ProfilePictureCropper
file={cropperFile}
opened={cropperOpen}
onClose={() => {
setCropperOpen(false);
setCropperFile(null);
}}
onCropComplete={handleCropComplete}
/>
{isAnonymous && <Divider />}
{isAnonymous && (
<div>
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('config.account.upgrade.title', 'Upgrade Guest Account')}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('config.account.upgrade.description', 'Link your account to preserve your history and access more features!')}
</p>
</div>
{upgradeError && (
<Alert color="red" mb="md">
{upgradeError}
</Alert>
)}
{success && (
<Alert color="green" mb="md">
{success}
</Alert>
)}
<div style={{ marginBottom: '1rem' }}>
<Text size="sm" fw={500} mb="xs" c="var(--mantine-color-dimmed)">
{t('config.account.upgrade.socialLogin', 'Upgrade with Social Account')}
</Text>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{oauthProviders
.filter(provider => !provider.isDisabled)
.map((provider) => (
<Tooltip
key={provider.id}
content={`${t('config.account.upgrade.linkWith', 'Link with')} ${provider.label}`}
>
<Button
variant="outline"
size="sm"
leftSection={
<Image
src={`${BASE_PATH}/Login/${provider.file}`}
alt={provider.label}
style={{ width: 16, height: 16 }}
/>
}
onClick={() => handleOAuthUpgrade(provider.id as any)}
disabled={isLoading}
>
{provider.label}
</Button>
</Tooltip>
))}
</div>
</div>
<div>
<Text size="sm" fw={500} mb="xs" c="var(--mantine-color-dimmed)">
{t('config.account.upgrade.emailPassword', 'or enter your email & password')}
</Text>
<form onSubmit={handleEmailUpgrade}>
<Group align="end" gap="sm">
<TextInput
label={t('config.account.upgrade.email', 'Email')}
placeholder={t('config.account.upgrade.emailPlaceholder', 'Enter your email')}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{ flex: 1 }}
/>
<PasswordInput
label={t('config.account.upgrade.password', 'Password (optional)')}
placeholder={t('config.account.upgrade.passwordPlaceholder', 'Set a password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
description={t('config.account.upgrade.passwordNote', 'Leave empty to use email verification only')}
style={{ flex: 1 }}
/>
<Button type="submit" disabled={isLoading}>
{t('config.account.upgrade.upgradeButton', 'Upgrade Account')}
</Button>
</Group>
</form>
</div>
</div>
)}
{/* Delete Account Section */}
{!isAnonymous && (
<div style={{
marginTop: 'auto',
paddingTop: '1.5rem',
display: 'flex',
justifyContent: 'flex-end',
borderTop: '1px solid var(--mantine-color-default-border)'
}}>
<Button
color="red"
variant="outline"
onClick={() => setDeleteModalOpen(true)}
>
{t('config.account.overview.deleteAccount', 'Delete Account')}
</Button>
</div>
)}
{/* Delete Account Confirmation Modal */}
<Modal
opened={deleteModalOpen}
onClose={closeDeleteModal}
title={t('config.account.overview.deleteAccountTitle', 'Delete Account')}
centered
zIndex={10000}
>
<form onSubmit={(e) => {
e.preventDefault();
if (confirmEmail.toLowerCase() === user?.email?.toLowerCase()) {
handleDeleteAccount();
}
}}>
<Text size="sm" mb="md">
{t('config.account.overview.deleteWarning',
'This action is permanent and cannot be undone. All your data will be deleted.')}
</Text>
<Text size="sm" fw={500} mb="xs">
{t('config.account.overview.enterEmailConfirm',
'To confirm deletion, please type your email address ({{email}}) below:',
{ email: user?.email })}
</Text>
<TextInput
placeholder={user?.email || ''}
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
mb="md"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeDeleteModal} type="button">
{t('cancel', 'Cancel')}
</Button>
<Button
color="red"
disabled={confirmEmail.toLowerCase() !== user?.email?.toLowerCase()}
type="submit"
loading={isDeletingAccount}
>
{t('config.account.overview.confirmDelete', 'Delete My Account')}
</Button>
</Group>
</form>
</Modal>
</div>
);
};
export default Overview;
@@ -0,0 +1,123 @@
import React, { useState } from 'react';
import { Button, PasswordInput, Group, Alert, LoadingOverlay, Modal, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { supabase } from '@app/auth/supabase';
import { Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
const PasswordSecurity: React.FC = () => {
const { t } = useTranslation();
const { refreshSession } = useAuth();
const [opened, setOpened] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [didUpdate, setDidUpdate] = useState(false);
const handleChangePassword = async () => {
if (!newPassword || !confirmPassword) {
setError(t('signup.pleaseFillAllFields', 'Please fill in all fields'));
return;
}
if (newPassword.length < 6) {
setError(t('signup.passwordTooShort', 'Password must be at least 6 characters long'));
return;
}
if (newPassword !== confirmPassword) {
setError(t('signup.passwordsDoNotMatch', 'Passwords do not match'));
return;
}
try {
setIsLoading(true);
setError(null);
setSuccess(null);
// Update to the new password directly
const { error: updateError } = await supabase.auth.updateUser({ password: newPassword });
if (updateError) {
setError(updateError.message);
return;
}
setSuccess(t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.'));
setNewPassword('');
setConfirmPassword('');
setDidUpdate(true);
// Replace form with success text, then close after 2s
setTimeout(() => {
// refresh session after closing to avoid UI jank
void refreshSession();
setOpened(false);
setDidUpdate(false);
}, 2000);
} catch (e: any) {
setError(e?.message || 'Failed to change password');
} finally {
setIsLoading(false);
}
};
return (
<div style={{ position: 'relative' }}>
<LoadingOverlay visible={isLoading} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('config.account.security.title', 'Passwords & Security')}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('config.account.security.description', 'Manage your password and security settings.')}
</p>
</div>
<Button type="button" onClick={() => setOpened(true)} variant="filled">
{t('config.account.security.changePassword', 'Change password')}
</Button>
</div>
<Modal opened={opened} onClose={() => setOpened(false)} centered title={t('config.account.security.changePassword', 'Change password')} zIndex={Z_INDEX_OVER_SETTINGS_MODAL}>
{error && (
<Alert color="red" mb="md">{error}</Alert>
)}
{didUpdate ? (
<Alert color="green" mb="md">{success || t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.')}</Alert>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<PasswordInput
label={t('account.newPassword', 'New Password')}
placeholder={t('account.newPassword', 'New Password')}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
/>
<PasswordInput
label={t('account.confirmNewPassword', 'Confirm New Password')}
placeholder={t('account.confirmNewPassword', 'Confirm New Password')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
/>
<Divider my="sm" />
<Group justify="flex-end">
<Button type="button" variant="default" onClick={() => setOpened(false)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="button" onClick={handleChangePassword} loading={isLoading}>
{t('config.account.security.update', 'Update password')}
</Button>
</Group>
</div>
)}
</Modal>
</div>
);
};
export default PasswordSecurity;
@@ -0,0 +1,201 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Divider, Loader, Alert, Select, Group, Text } from '@mantine/core';
import { usePlans, PlanTier } from '@app/hooks/usePlans';
import StripeCheckout, { PurchaseType, CreditsPack, PlanID } from '@app/components/shared/StripeCheckoutSaas';
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
import ApiPackagesSection from '@app/components/shared/config/configSections/plan/ApiPackagesSection';
import ActivePlanSection from '@app/components/shared/config/configSections/plan/ActivePlanSection';
import { useAuth } from '@app/auth/UseSession';
const Plan: React.FC = () => {
const [checkoutOpen, setCheckoutOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<PlanTier | null>(null);
const [selectedCredits, setSelectedCredits] = useState(0); // Index of selected credit package
const [purchaseType, setPurchaseType] = useState<PurchaseType>('subscription');
const [selectedCreditsPack, setSelectedCreditsPack] = useState<CreditsPack>(null);
const [currency, setCurrency] = useState<string>('gbp');
const { trialStatus } = useAuth();
const { data, loading, error, updateCurrentPlan } = usePlans(currency);
const currencyOptions = [
{ value: 'cny', label: 'Chinese yuan (CNY, ¥)' },
{ value: 'usd', label: 'US dollar (USD, $)' },
{ value: 'inr', label: 'Indian rupee (INR, ₹)' },
{ value: 'brl', label: 'Brazilian real (BRL, R$)' },
{ value: 'eur', label: 'Euro (EUR, €)' },
{ value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' },
{ value: 'gbp', label: 'British pound (GBP, £)' }
];
const handleUpgradeClick = useCallback((plan: PlanTier) => {
if (!data) return;
if (plan.isContactOnly) {
// Open contact form or redirect to contact page
window.open('mailto:[email protected]?subject=Enterprise Plan Inquiry', '_blank');
return;
}
if (plan.id !== data.currentPlan.id) {
setSelectedPlan(plan);
setPurchaseType('subscription');
setSelectedCreditsPack(null);
setCheckoutOpen(true);
}
}, [data]);
const handleCreditPurchaseClick = useCallback((creditsPack: CreditsPack) => {
if (!data) return;
setSelectedCreditsPack(creditsPack);
setPurchaseType('credits');
setSelectedPlan(null);
setCheckoutOpen(true);
}, [data]);
const handlePaymentSuccess = useCallback((sessionId: string) => {
console.log('Payment successful, session:', sessionId);
// Update local state immediately - no page reload needed
if (selectedPlan && purchaseType === 'subscription') {
updateCurrentPlan(selectedPlan.id);
}
// Close modal after brief delay to show success message
setTimeout(() => {
setCheckoutOpen(false);
setSelectedPlan(null);
setSelectedCreditsPack(null);
}, 2000);
}, [selectedPlan, purchaseType, updateCurrentPlan]);
const handlePaymentError = useCallback((error: string) => {
console.error('Payment error:', error);
// Error is already displayed in the StripeCheckout component
}, []);
const handleCheckoutClose = useCallback(() => {
setCheckoutOpen(false);
setSelectedPlan(null);
setSelectedCreditsPack(null);
}, []);
const handleAddPaymentClick = useCallback(() => {
if (!data) return;
// Find Pro plan from available plans
const proPlan = Array.from(data.plans.values()).find(plan => plan.id === 'pro');
if (proPlan) {
setSelectedPlan(proPlan);
setPurchaseType('subscription');
setSelectedCreditsPack(null);
setCheckoutOpen(true);
}
}, [data]);
// Check URL parameters for action=add-payment
useEffect(() => {
if (!data) return;
const params = new URLSearchParams(window.location.search);
if (params.get('action') === 'add-payment') {
handleAddPaymentClick();
// Clean up URL
params.delete('action');
const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname;
window.history.replaceState({}, '', newUrl);
}
}, [data, handleAddPaymentClick]);
// Early returns after all hooks are called
if (loading) {
return (
<div className="flex justify-center items-center py-8">
<Loader size="lg" />
</div>
);
}
if (error) {
return (
<Alert color="red" title="Error loading plans">
{error}
</Alert>
);
}
if (!data) {
return (
<Alert color="yellow" title="No data available">
Plans data is not available at the moment.
</Alert>
);
}
const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } = data;
const plansArray = Array.from(plans.values());
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* Currency Selector */}
<div>
<Group justify="space-between" align="center" mb="md">
<Text size="lg" fw={600}>Currency</Text>
<Select
value={currency}
onChange={(value) => setCurrency(value || 'gbp')}
data={currencyOptions}
searchable
clearable={true}
w={300}
/>
</Group>
</div>
<ActivePlanSection
currentPlan={currentPlan}
_activeSince={activeSince}
_nextBillingDate={nextBillingDate}
trialStatus={trialStatus ?? undefined}
onAddPaymentClick={handleAddPaymentClick}
/>
<Divider />
<AvailablePlansSection
plans={plansArray}
currentPlan={currentPlan}
onUpgradeClick={handleUpgradeClick}
/>
<Divider />
<ApiPackagesSection
apiPackages={apiPackages}
selectedCredits={selectedCredits}
onSelectedCreditsChange={setSelectedCredits}
onCreditPurchaseClick={handleCreditPurchaseClick}
/>
{/* Stripe Checkout Modal */}
<StripeCheckout
opened={checkoutOpen && (selectedPlan !== null || selectedCreditsPack !== null)}
onClose={handleCheckoutClose}
purchaseType={purchaseType}
planId={purchaseType === 'subscription' ? (selectedPlan?.id as PlanID) : null}
creditsPack={purchaseType === 'credits' ? selectedCreditsPack : null}
planName={
purchaseType === 'subscription'
? selectedPlan?.name || ''
: data?.apiPackages.find(pkg => pkg.id === selectedCreditsPack)?.name || ''
}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
isTrialConversion={trialStatus?.isTrialing && purchaseType === 'subscription'}
/>
</div>
);
};
export default Plan;
@@ -0,0 +1,139 @@
import React from "react";
import {
Paper,
Stack,
Group,
Text,
Divider,
} from "@mantine/core";
import StackedBarChart from "@app/components/shared/charts/StackedBarChart";
import { FractionData } from '@app/types/charts';
import { ApiCredits as ApiUsage } from "@app/types/credits";
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
import { formatUTC } from "@app/components/shared/utils/date";
import { useTranslation } from "react-i18next";
// Using shared ApiCredits type as ApiUsage
interface UsageSectionProps {
apiUsage: ApiUsage;
obscured?: boolean;
overlayMessage?: string;
loading?: boolean;
}
export default function UsageSection({ apiUsage, obscured, overlayMessage, loading }: UsageSectionProps) {
const { t } = useTranslation();
const weeklyUsed = apiUsage.weeklyCreditsAllocated - apiUsage.weeklyCreditsRemaining;
const boughtUsed = apiUsage.totalBoughtCredits - apiUsage.boughtCreditsRemaining;
// Totals for overall usage visualization
const totalRemaining = Math.max(apiUsage.totalAvailableCredits, 0);
const formatDate = (iso: string, withTime: boolean) => formatUTC(iso, withTime);
// Prepare data for the stacked bar chart
const fractions: FractionData[] = [
{
name: t('config.apiKeys.includedCredits', 'Included credits'),
numerator: Math.max(0, weeklyUsed),
denominator: Math.max(0, apiUsage.weeklyCreditsAllocated),
numeratorLabel: t('common.used', 'used'),
denominatorLabel: t('common.available', 'available'),
color: "var(--usage-weekly-active)"
},
{
name: t('config.apiKeys.purchasedCredits', 'Purchased credits'),
numerator: Math.max(0, boughtUsed),
denominator: Math.max(0, apiUsage.totalBoughtCredits),
numeratorLabel: t('common.used', 'used'),
denominatorLabel: t('common.available', 'available'),
color: "var(--usage-bought-active)"
},
];
return (
<div style={{ position: "relative" }}>
<Paper radius="md" p={18} style={{ background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
<Stack gap={12}
style={{
fontFamily: 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"',
}}
>
<Group justify="space-between">
<Text fw={500}>
{t('config.apiKeys.creditsRemaining', 'Credits Remaining')}: {loading ? (
<SkeletonLoader type="block" width={40} height={14} />
) : (
totalRemaining
)}
</Text>
</Group>
<StackedBarChart
fractions={fractions}
width={640}
height={22}
showLegend={true}
tooltipPosition="top"
loading={Boolean(loading)}
animate={!loading}
animationDurationMs={900}
ariaLabel={t('config.apiKeys.chartAriaLabel', {
includedUsed: Math.max(0, weeklyUsed),
includedTotal: Math.max(0, apiUsage.weeklyCreditsAllocated),
purchasedUsed: Math.max(0, boughtUsed),
purchasedTotal: Math.max(0, apiUsage.totalBoughtCredits),
defaultValue: 'Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}'
})}
/>
<Divider my={4} />
<Group justify="space-between" wrap="wrap">
<Group gap="lg">
<Text size="sm" c="dimmed">
{t('config.apiKeys.nextReset', 'Next Reset')}: {loading ? (
<SkeletonLoader type="block" width={120} height={12} />
) : (
formatDate(apiUsage.weeklyResetDate, false)
)}
</Text>
<Text size="sm" c="dimmed">
{t('config.apiKeys.lastApiUse', 'Last API Use')}: {loading ? (
<SkeletonLoader type="block" width={160} height={12} />
) : (
formatDate(apiUsage.lastApiUsage, true)
)}
</Text>
</Group>
</Group>
</Stack>
</Paper>
{obscured && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
padding: 16,
color: "var(--mantine-color-text)",
background: "rgba(16, 18, 27, 0.55)",
backdropFilter: "blur(6px)",
WebkitBackdropFilter: "blur(6px)",
borderRadius: 12,
border: "1px solid rgba(255,255,255,0.06)",
}}
>
<Text size="sm" c="dimmed">
{overlayMessage || t('config.apiKeys.overlayMessage', 'Generate a key to see credits and available credits')}
</Text>
</div>
)}
</div>
);
}
@@ -0,0 +1,70 @@
import { useCallback, useEffect, useState } from "react";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
import { isUserAnonymous } from "@app/auth/supabase";
export function useApiKey() {
const { session, loading, user } = useAuth();
const isAnonymous = Boolean(user && isUserAnonymous(user));
const [apiKey, setApiKey] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
const fetchKey = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
// Backend is POST for get and update
const res = await apiClient.post("/api/v1/user/get-api-key");
const value = typeof res.data === "string" ? res.data : res.data?.apiKey;
if (typeof value === "string") setApiKey(value);
} catch (e: any) {
// If not found, try to create one by calling update endpoint
if (e?.response?.status === 404) {
try {
const createRes = await apiClient.post("/api/v1/user/update-api-key");
const created =
typeof createRes.data === "string"
? createRes.data
: createRes.data?.apiKey;
if (typeof created === "string") setApiKey(created);
} catch (createErr: any) {
setError(createErr);
}
} else {
setError(e);
}
} finally {
setIsLoading(false);
setHasAttempted(true);
}
}, []);
const refresh = useCallback(async () => {
setIsRefreshing(true);
setError(null);
try {
const res = await apiClient.post("/api/v1/user/update-api-key");
const value = typeof res.data === "string" ? res.data : res.data?.apiKey;
if (typeof value === "string") setApiKey(value);
} catch (e: any) {
setError(e);
} finally {
setIsRefreshing(false);
}
}, []);
useEffect(() => {
if (!loading && session && !hasAttempted && !isAnonymous) {
fetchKey();
}
}, [loading, session, hasAttempted, isAnonymous, fetchKey]);
return { apiKey, isLoading, isRefreshing, error, refetch: fetchKey, refresh, hasAttempted } as const;
}
export default useApiKey;
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useState } from "react";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
import { ApiCredits } from "@app/types/credits";
import { isUserAnonymous } from "@app/auth/supabase";
function coerceNumber(value: unknown, fallback = 0): number {
const n = typeof value === "string" ? Number(value) : (value as number);
return Number.isFinite(n) ? (n as number) : fallback;
}
function normalizeCredits(raw: any): ApiCredits {
// Accept a variety of possible backend keys to be resilient
return {
weeklyCreditsRemaining: coerceNumber(
raw?.weeklyCreditsRemaining ?? raw?.weeklyRemaining ?? raw?.weekly_left
),
weeklyCreditsAllocated: coerceNumber(
raw?.weeklyCreditsAllocated ?? raw?.weeklyAllocated ?? raw?.weekly_total
),
boughtCreditsRemaining: coerceNumber(
raw?.boughtCreditsRemaining ?? raw?.boughtRemaining ?? raw?.bought_left
),
totalBoughtCredits: coerceNumber(
raw?.totalBoughtCredits ?? raw?.boughtTotal ?? raw?.bought_total
),
totalAvailableCredits: coerceNumber(
raw?.totalAvailableCredits ?? raw?.totalRemaining ?? raw?.available_total
),
weeklyResetDate:
(raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date) || "",
lastApiUsage:
(raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at) || "",
};
}
export function useCredits() {
const { session, loading, user } = useAuth();
const isAnonymous = Boolean(user && isUserAnonymous(user));
const [data, setData] = useState<ApiCredits | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
const fetchCredits = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const res = await apiClient.get("/api/v1/credits");
const normalized = normalizeCredits(res.data);
// If backend returns an "empty" payload, keep data null so the UI stays in loading/skeleton
const isEmpty = !normalized.weeklyCreditsAllocated &&
!normalized.weeklyCreditsRemaining &&
!normalized.totalBoughtCredits &&
!normalized.boughtCreditsRemaining &&
!normalized.totalAvailableCredits &&
!normalized.weeklyResetDate &&
!normalized.lastApiUsage;
setData(isEmpty ? null : normalized);
} catch (e: any) {
setError(e);
} finally {
setIsLoading(false);
setHasAttempted(true);
}
}, []);
useEffect(() => {
if (!loading && session && !hasAttempted && !isAnonymous) {
fetchCredits();
}
}, [loading, session, hasAttempted, isAnonymous, fetchCredits]);
return { data, isLoading, error, refetch: fetchCredits, hasAttempted } as const;
}
export default useCredits;
@@ -0,0 +1,125 @@
import React from 'react';
import { Card, Text, Group, Flex, Alert, Button, Badge } from '@mantine/core';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CreditCardIcon from '@mui/icons-material/CreditCard';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/usePlans';
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
interface TrialStatus {
isTrialing: boolean;
trialEnd: string;
daysRemaining: number;
hasPaymentMethod: boolean;
hasScheduledSub: boolean;
}
interface ActivePlanSectionProps {
currentPlan: PlanTier;
_activeSince?: string;
_nextBillingDate?: string;
trialStatus?: TrialStatus;
onAddPaymentClick?: () => void;
}
const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
currentPlan,
_activeSince,
_nextBillingDate,
trialStatus,
onAddPaymentClick
}) => {
const { t } = useTranslation();
return (
<div>
<Flex justify="space-between" align="center">
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.activePlan.title', 'Active Plan')}
</h3>
<ManageBillingButton
returnUrl={`${window.location.origin}/account`}
trialStatus={trialStatus}
/>
</Flex>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('plan.activePlan.subtitle', 'Your current subscription details')}
</p>
{/* Trial Status Alert */}
{trialStatus?.isTrialing && (
<Alert
color="blue"
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
mt="md"
mb="md"
title={t('plan.trial.title', 'Free Trial Active')}
>
<Text size="sm">
{t('plan.trial.daysRemaining', 'Your trial ends in {{days}} days', {
days: trialStatus.daysRemaining
})}
</Text>
<Text size="xs" c="dimmed">
{t('plan.trial.endDate', 'Expires: {{date}}', {
date: new Date(trialStatus.trialEnd).toLocaleDateString()
})}
</Text>
{trialStatus.hasScheduledSub ? (
<Text size="xs" c="green" fw={500} mt="sm">
{t('plan.trial.subscriptionScheduled', 'Subscription scheduled - starts {{date}}', {
date: new Date(trialStatus.trialEnd).toLocaleDateString()
})}
</Text>
) : (
onAddPaymentClick && (
<Button
size="xs"
variant="light"
mt="sm"
onClick={onAddPaymentClick}
leftSection={<CreditCardIcon sx={{ fontSize: 14 }} />}
>
{t('plan.trial.subscribeToPro', 'Subscribe to Pro')}
</Button>
)
)}
</Alert>
)}
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" align="center">
<div>
<Group gap="xs">
<Text size="lg" fw={600}>
{currentPlan.name}
</Text>
{trialStatus?.isTrialing && (
<Badge color="blue" variant="light">
{t('plan.trial.badge', 'Trial')}
</Badge>
)}
</Group>
{/* {activeSince && (
<Text size="sm" c="dimmed">
{t('plan.activeSince', 'Active since {{date}}', { date: activeSince })}
</Text>
)} */}
</div>
<div className="text-right">
<Text size="xl" fw={700}>
{currentPlan.currency}{currentPlan.price}/month
</Text>
{/* {nextBillingDate && (
<Text size="sm" c="dimmed">
{t('plan.nextBilling', 'Next billing: {{date}}', { date: nextBillingDate })}
</Text>
)} */}
</div>
</Group>
</Card>
</div>
);
};
export default ActivePlanSection;
@@ -0,0 +1,101 @@
import React from 'react';
import { Button, Card, Text, Stack, Flex, Slider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { CreditsPack } from '@app/components/shared/StripeCheckoutSaas';
interface ApiPackage {
id: string;
name: string;
credits: number;
price: number;
currency: string;
description: string;
}
interface ApiPackagesSectionProps {
apiPackages: ApiPackage[];
selectedCredits: number;
onSelectedCreditsChange: (value: number) => void;
onCreditPurchaseClick: (creditsPack: CreditsPack) => void;
}
const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
apiPackages,
selectedCredits,
onSelectedCreditsChange,
onCreditPurchaseClick
}) => {
const { t } = useTranslation();
return (
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.apiPackages.title', 'API Credit Packages')}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('plan.apiPackages.subtitle', 'Purchase API credits for your applications')}
</p>
<Card padding="xl" radius="md" className="mb-4">
<Stack gap="xl">
{/* Credits Selection */}
<div>
<Text size="lg" fw={600} mb="md">
{t('plan.selectCredits', 'Select Credit Amount')}
</Text>
<div className="px-4">
<Slider
value={selectedCredits}
onChange={onSelectedCreditsChange}
onChangeEnd={(value) => onSelectedCreditsChange(Math.round(value))}
min={0}
max={3}
step={0.01}
marks={[
{ value: 0, label: '100' },
{ value: 1, label: '500' },
{ value: 2, label: '1K' },
{ value: 3, label: '5K' }
]}
size="lg"
className="mb-6"
label={null}
/>
</div>
</div>
{/* Selected Package Display */}
<Flex gap={"xl"} justify="space-between" align="center">
<div>
<Text size="xl" fw={700}>
{apiPackages[Math.round(selectedCredits)].credits.toLocaleString()} Credits
</Text>
<Text size="sm" c="dimmed">
{apiPackages[Math.round(selectedCredits)].description}
</Text>
</div>
<div className="">
<Text size="xl" fw={700}>
{apiPackages[Math.round(selectedCredits)].currency}{apiPackages[Math.round(selectedCredits)].price}
</Text>
<Text size="sm" c="dimmed">
{t('plan.totalCost', 'Total Cost')}
</Text>
</div>
<Button
size="lg"
onClick={() => onCreditPurchaseClick(apiPackages[Math.round(selectedCredits)].id as CreditsPack)}
>
{t('plan.purchase', 'Purchase')}
</Button>
</Flex>
</Stack>
</Card>
</div>
);
};
export default ApiPackagesSection;
@@ -0,0 +1,123 @@
import React, { useState } from 'react';
import { Button, Card, Badge, Text, Collapse } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/usePlans';
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
interface AvailablePlansSectionProps {
plans: PlanTier[];
currentPlan?: PlanTier;
currentLicenseInfo?: any;
onUpgradeClick: (plan: PlanTier | any) => void; // Accept PlanTierGroup for compatibility
onManageClick?: (plan: PlanTier | any) => void; // Accept PlanTierGroup for compatibility
currency?: string;
onCurrencyChange?: (currency: string) => void;
currencyOptions?: Array<{ value: string; label: string }>;
loginEnabled?: boolean;
}
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
plans,
currentPlan,
onUpgradeClick
}) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
const isUserProOrAbove = currentPlan?.id === 'pro' || currentPlan?.id === 'enterprise';
return (
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.availablePlans.title', 'Available Plans')}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')}
</p>
<div className="flex h-[20rem] mb-4 " style={{ gap: '1rem', overflowX: 'auto' }}>
{plans.map(plan => (
<PlanCard
key={plan.id}
plan={plan}
isCurrentPlan={plan.id === currentPlan?.id}
isUserProOrAbove={isUserProOrAbove}
onUpgradeClick={onUpgradeClick}
/>
))}
</div>
<div className="text-center">
<Button
variant="subtle"
onClick={() => setShowComparison(!showComparison)}
>
{showComparison
? t('plan.hideComparison', 'Hide Feature Comparison')
: t('plan.showComparison', 'Compare All Features')
}
</Button>
</div>
<Collapse in={showComparison}>
<Card padding="lg" radius="md" withBorder className="mt-4">
<Text size="lg" fw={600} mb="md">
{t('plan.featureComparison', 'Feature Comparison')}
</Text>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-2">{t('plan.feature.title', 'Feature')}</th>
{plans.map(plan => (
<th key={plan.id} className="text-center p-2 min-w-24 relative">
{plan.name}
{plan.popular && (
<Badge
color="blue"
variant="filled"
style={{
position: 'absolute',
top: '0rem',
right: '-2rem',
fontSize: '0.5rem',
fontWeight: '500',
height: '1rem',
padding: '0 0.1rem'
}}
>
{t('plan.popular', 'Popular')}
</Badge>
)}
</th>
))}
</tr>
</thead>
<tbody>
{plans[0].features.map((_, featureIndex) => (
<tr key={featureIndex} className="border-b">
<td className="p-2">
{plans[0].features[featureIndex].name}
</td>
{plans.map(plan => (
<td key={plan.id} className="text-center p-2">
{plan.features[featureIndex].included ? (
<Text c="green" fw={600}></Text>
) : (
<Text c="gray">-</Text>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
</Collapse>
</div>
);
};
export default AvailablePlansSection;
@@ -0,0 +1,98 @@
import React from 'react';
import { Button, Card, Badge, Text, Group, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/usePlans';
interface PlanCardProps {
plan?: PlanTier;
planGroup?: any; // For proprietary PlanTierGroup compatibility
isCurrentPlan?: boolean;
isCurrentTier?: boolean;
isDowngrade?: boolean;
isUserProOrAbove?: boolean;
currentLicenseInfo?: any;
currentTier?: string | null; // Accept null for proprietary compatibility
onUpgradeClick?: (plan: any) => void; // Accept PlanTierGroup or PlanTier
onManageClick?: (plan: any) => void;
loginEnabled?: boolean;
}
const PlanCard: React.FC<PlanCardProps> = ({
plan: propPlan,
planGroup,
isCurrentPlan,
isCurrentTier: _isCurrentTier,
isDowngrade: _isDowngrade,
isUserProOrAbove,
currentLicenseInfo: _currentLicenseInfo,
currentTier: _currentTier,
onUpgradeClick,
onManageClick: _onManageClick,
loginEnabled: _loginEnabled
}) => {
// Use plan from props, or extract from planGroup if proprietary is using it
const plan = propPlan || (planGroup as any)?.monthly || (planGroup as any)?.yearly;
const { t } = useTranslation();
if (!plan) return null; // Safety check
const shouldHideUpgrade = plan.id === 'free' && isUserProOrAbove;
return (
<Card key={plan.id} padding="lg" radius="sm" withBorder className="h-full w-[33%] relative">
{plan.popular && (
<Badge
variant="filled"
size='xs'
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
>
{t('plan.popular', 'Popular')}
</Badge>
)}
<Stack gap="md" className="h-full">
<div>
<Text size="lg" fw={600}>{plan.name}</Text>
<Group gap="xs" align="baseline">
<Text size="2xl" fw={700}>
{plan.isContactOnly ? t('plan.customPricing', 'Custom') : `${plan.currency}${plan.price}`}
</Text>
{!plan.isContactOnly && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
</div>
<Stack gap="xs">
{plan.highlights.map((highlight: string, index: number) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div className="flex-grow" />
{!shouldHideUpgrade && (
<Button
variant={isCurrentPlan ? "filled" : plan.isContactOnly ? "outline" : "filled"}
disabled={isCurrentPlan}
fullWidth
onClick={() => onUpgradeClick?.(plan)}
>
{isCurrentPlan
? t('plan.current', 'Current Plan')
: plan.isContactOnly
? t('plan.contact', 'Get in Touch')
: t('plan.upgrade', 'Upgrade')
}
</Button>
)}
</Stack>
</Card>
);
};
export default PlanCard;
@@ -0,0 +1,153 @@
import React from 'react';
import { type TFunction } from 'i18next';
import {
createConfigNavSections as createCoreConfigNavSections,
type ConfigNavSection,
} from '@core/components/shared/config/configNavSections';
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
import PasswordSecurity from '@app/components/shared/config/configSections/PasswordSecurity';
import ApiKeys from '@app/components/shared/config/configSections/ApiKeys';
import Plan from '@app/components/shared/config/configSections/Plan';
type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>;
interface CreateSaasConfigNavSectionsOptions {
isDev?: boolean;
isAnonymous?: boolean;
t: TFunction<'translation', undefined>;
}
function ensurePreferencesSection(sections: ConfigNavSection[]): ConfigNavSection[] {
const preferencesIndex = sections.findIndex(section => section.title === 'Preferences');
if (preferencesIndex === -1) {
return [
...sections,
{
title: 'Preferences',
items: [
{
key: 'general',
label: 'General',
icon: 'settings-rounded',
component: <GeneralSection />,
},
{
key: 'hotkeys',
label: 'Keyboard Shortcuts',
icon: 'keyboard-rounded',
component: <HotkeysSection />,
},
],
},
];
}
return sections;
}
function appendDeveloperSection(sections: ConfigNavSection[]): ConfigNavSection[] {
const hasDeveloper = sections.some(section =>
section.items.some(item => item.key === 'developer' || item.key === 'api-keys'),
);
if (hasDeveloper) {
return sections;
}
return [
...sections,
{
title: 'Developer',
items: [
{
key: 'api-keys',
label: 'API Keys',
icon: 'key-rounded',
component: <ApiKeys />,
},
],
},
];
}
function appendBillingSection(
sections: ConfigNavSection[],
t: TFunction<'translation', undefined>,
): ConfigNavSection[] {
const hasPlan = sections.some(section =>
section.items.some(item => item.key === 'plan'),
);
if (hasPlan) {
return sections;
}
return [
...sections,
{
title: 'Billing',
items: [
{
key: 'plan',
label: t('config.plan', 'Plan'),
icon: 'credit-card',
component: <Plan />,
},
],
},
];
}
export function createSaasConfigNavSections(
Overview: OverviewComponent,
onLogoutClick: () => void,
{ isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions,
): ConfigNavSection[] {
const baseSections = createCoreConfigNavSections(false, false, false);
// Create Account section as the first section with Overview and Passwords & Security
const accountSection: ConfigNavSection = {
title: t('config.account.overview.title', 'Account Settings'),
items: [
{
key: 'overview',
label: t('config.account.overview.label', 'Overview'),
icon: 'account-circle',
component: <Overview onLogoutClick={onLogoutClick} />,
},
{
key: 'security',
label: 'Passwords & Security',
icon: 'lock',
component: <PasswordSecurity />,
},
],
};
let sections = [accountSection, ...baseSections];
// Suppress OSS-only sections (update checker, login config banner) not relevant in SaaS
sections = sections.map(section => ({
...section,
items: section.items.map(item =>
item.key === 'general'
? { ...item, component: <GeneralSection hideUpdateSection hideAdminBanner /> }
: item
),
}));
sections = ensurePreferencesSection(sections);
sections = appendDeveloperSection(sections);
if (!isAnonymous) {
sections = appendBillingSection(sections, t);
}
if (isDev) {
console.debug('[AppConfigModal] SaaS navigation sections', sections);
}
return sections;
}
@@ -0,0 +1,67 @@
// Re-export from core for compatibility
// Override VALID_NAV_KEYS to include saas-specific keys
export const VALID_NAV_KEYS = [
'overview',
'password-security',
'preferences',
'notifications',
'connections',
'general',
'people',
'teams',
'security',
'identity',
'plan',
'payments',
'requests',
'developer',
'api-keys',
'hotkeys',
'adminGeneral',
'adminSecurity',
'adminConnections',
'adminPrivacy',
'adminDatabase',
'adminAdvanced',
'adminLegal',
'adminPremium',
'adminFeatures',
'adminPlan',
'adminAudit',
'adminUsage',
'adminEndpoints'
] as const;
// Extend NavKey to include saas-specific keys
export type NavKey =
| 'overview'
| 'password-security'
| 'preferences'
| 'notifications'
| 'connections'
| 'general'
| 'people'
| 'teams'
| 'security'
| 'identity'
| 'plan'
| 'payments'
| 'requests'
| 'developer'
| 'api-keys'
| 'hotkeys'
| 'adminGeneral'
| 'adminSecurity'
| 'adminConnections'
| 'adminPrivacy'
| 'adminDatabase'
| 'adminAdvanced'
| 'adminLegal'
| 'adminPremium'
| 'adminFeatures'
| 'adminPlan'
| 'adminAudit'
| 'adminUsage'
| 'adminEndpoints';
// some of these are not used yet, but appear in figma designs
@@ -0,0 +1,14 @@
export function formatUTC(iso: string, withTime: boolean): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "-";
const formatted = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
...(withTime ? { hour: "2-digit", minute: "2-digit", hour12: false } : {}),
timeZone: "UTC",
}).format(date);
return withTime ? `${formatted} UTC` : formatted;
}
@@ -0,0 +1,209 @@
/* Toast Container Styles */
.toast-container {
position: fixed;
z-index: 1200;
display: flex;
gap: 12px;
pointer-events: none;
}
.toast-container--top-left {
top: 16px;
left: 16px;
flex-direction: column;
}
.toast-container--top-right {
top: 16px;
right: 16px;
flex-direction: column;
}
.toast-container--bottom-left {
bottom: 16px;
left: 16px;
flex-direction: column-reverse;
}
.toast-container--bottom-right {
bottom: 16px;
right: 16px;
flex-direction: column-reverse;
}
/* Toast Item Styles */
.toast-item {
min-width: 320px;
max-width: 560px;
box-shadow: var(--shadow-lg);
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: auto;
}
/* Toast Alert Type Colors */
.toast-item--success {
background: var(--color-green-100);
color: var(--text-primary);
border: 1px solid var(--color-green-400);
}
.toast-item--error {
background: var(--color-red-100);
color: var(--text-primary);
border: 1px solid var(--color-red-400);
}
.toast-item--warning {
background: var(--color-orange-100); /* deeper orange background */
color: var(--text-primary);
border: 1px solid var(--color-orange-300); /* deeper orange border */
}
.toast-item--neutral {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border-default);
}
/* Toast Header Row */
.toast-header {
display: flex;
align-items: center;
gap: 12px;
}
.toast-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.toast-title-container {
font-weight: 700;
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.toast-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.08);
color: inherit;
font-size: 12px;
font-weight: 700;
}
.toast-controls {
display: flex;
gap: 4px;
align-items: center;
}
.toast-button {
width: 28px;
height: 28px;
border-radius: 999px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.toast-expand-button {
transform: rotate(0deg);
transition: transform 160ms ease;
}
.toast-expand-button--expanded {
transform: rotate(180deg);
}
/* Progress Bar */
.toast-progress-container {
margin-top: 8px;
height: 6px;
background: var(--bg-muted);
border-radius: 999px;
overflow: hidden;
}
.toast-progress-bar {
height: 100%;
transition: width 160ms ease;
}
.toast-progress-bar--success {
background: var(--color-green-500);
}
.toast-progress-bar--error {
background: var(--color-red-500);
}
.toast-progress-bar--warning {
background: var(--color-orange-400); /* deeper orange for progress */
}
.toast-progress-bar--neutral {
background: var(--color-gray-500);
}
/* Toast Body */
.toast-body {
font-size: 14px;
opacity: 0.9;
margin-top: 8px;
}
/* Toast Action Button */
.toast-action-container {
margin-top: 12px;
display: flex;
justify-content: flex-start;
}
.toast-action-button {
padding: 8px 12px;
border-radius: 12px;
border: 1px solid;
background: transparent;
font-weight: 600;
cursor: pointer;
margin-left: auto;
}
.toast-action-button--success {
color: var(--text-primary);
border-color: var(--color-green-400);
}
.toast-action-button--error {
color: var(--text-primary);
border-color: var(--color-red-400);
}
.toast-action-button--warning {
color: var(--text-primary);
border-color: var(--color-orange-300);
}
.toast-action-button--neutral {
color: var(--text-primary);
border-color: var(--border-default);
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
// Re-export all constants from core
export * from '@core/constants/app';
// SaaS-specific overrides
// Get base URL with fallback (for use outside React components)
export const getBaseUrl = (): string => {
// Try to get from window object if set by app config
const baseUrl = (window as any).__STIRLING_PDF_BASE_URL__ || window.location.origin;
return baseUrl;
};
// Helper to set base URL (to be called when app config loads)
export const setBaseUrl = (url: string): void => {
(window as any).__STIRLING_PDF_BASE_URL__ = url;
};
@@ -0,0 +1,13 @@
export type OAuthProviderId = 'google' | 'apple' | 'github' | 'azure'
export type OAuthProviderMeta = {
id: OAuthProviderId
label: string
file: string
isDisabled: boolean
}
export const oauthProviders: readonly OAuthProviderMeta[] = [
{ id: 'google', label: 'Google', file: 'google.svg', isDisabled: false },
{ id: 'github', label: 'GitHub', file: 'github.svg', isDisabled: false },
] as const
+1
View File
@@ -0,0 +1 @@
export const devApiLink = "https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/";
@@ -0,0 +1,10 @@
/**
* SaaS stub core onboarding is suppressed in SaaS builds.
* OnboardingBootstrap calls these to clear any pending core tour state.
*/
export function useOnboarding() {
return {
clearPendingTourRequest: () => {},
setStartAfterToolModeSelection: (_value: boolean) => {},
};
}
@@ -0,0 +1,129 @@
import { useState, useEffect, useCallback } from 'react'
import { useLocation } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession'
import { signInAnonymously, supabase } from '@app/auth/supabase'
import { isAuthRoute, isHomeRoute, isToolRoute } from '@app/utils/pathUtils'
interface AutoAnonymousAuthState {
isAutoAuthenticating: boolean
autoAuthError: string | null
shouldTriggerAutoAuth: boolean
}
/**
* Automatically signs in users anonymously on direct tool access when not authenticated.
*/
export function useAutoAnonymousAuth() {
const { session, loading } = useAuth()
const location = useLocation()
const [state, setState] = useState<AutoAnonymousAuthState>({
isAutoAuthenticating: false,
autoAuthError: null,
shouldTriggerAutoAuth: false
})
const shouldAutoAuthenticate = useCallback((): boolean => {
const currentPath = location.pathname
if (isAuthRoute(currentPath)) return false
if (isHomeRoute(currentPath)) return false
return isToolRoute(currentPath)
}, [location.pathname])
const waitForToken = useCallback(async (timeoutMs = 7000) => {
// Prefer event-driven approach; fallback to polling if needed
let resolved = false
const done = (ok: boolean) => {
resolved = true
return ok
}
const hasToken = async () => {
const { data: { session } } = await supabase.auth.getSession()
return !!session?.access_token
}
if (await hasToken()) return true
const unsub = supabase.auth.onAuthStateChange(async (_evt, _session) => {
if (!resolved && (await hasToken())) {
unsub.data.subscription.unsubscribe()
done(true)
}
})
const started = Date.now()
while (!resolved && Date.now() - started < timeoutMs) {
if (await hasToken()) {
unsub.data.subscription.unsubscribe()
return done(true)
}
// gentle backoff
await new Promise(r => setTimeout(r, 120))
}
try { unsub.data.subscription.unsubscribe() } catch (err) {
// Ignore unsubscribe errors during cleanup
console.debug('[useAutoAnonymousAuth] Unsubscribe cleanup error:', err)
}
return done(false)
}, [])
const triggerAnonymousAuth = useCallback(async () => {
if (state.isAutoAuthenticating) return
setState(prev => ({ ...prev, isAutoAuthenticating: true, autoAuthError: null }))
try {
console.log('[useAutoAnonymousAuth] anonymous auth starting')
const { error } = await signInAnonymously()
if (error) throw error
// Wait for a usable token so first API calls won't 401/redirect
const ok = await waitForToken(7000)
if (!ok) {
throw new Error('Timed out waiting for anonymous session token')
}
console.log('[useAutoAnonymousAuth] anonymous auth complete')
setState(prev => ({ ...prev, isAutoAuthenticating: false, shouldTriggerAutoAuth: false }))
} catch (e) {
console.error('[useAutoAnonymousAuth] anonymous auth failed', e)
setState(prev => ({
...prev,
isAutoAuthenticating: false,
autoAuthError: e instanceof Error ? e.message : 'Anonymous authentication failed'
}))
}
}, [state.isAutoAuthenticating, waitForToken])
// Decide whether to auto-auth on mount & whenever location/auth changes
useEffect(() => {
if (loading) return
if (session) return
if (state.isAutoAuthenticating) return
const shouldAuth = shouldAutoAuthenticate()
if (state.shouldTriggerAutoAuth !== shouldAuth) {
setState(prev => ({ ...prev, shouldTriggerAutoAuth: shouldAuth }))
}
if (shouldAuth) {
console.log('[useAutoAnonymousAuth] tool route detected, auto-auth')
triggerAnonymousAuth()
}
}, [loading, session, state.isAutoAuthenticating, state.shouldTriggerAutoAuth, shouldAutoAuthenticate, triggerAnonymousAuth])
// Clear error if route is no longer a tool route, or once authenticated
useEffect(() => {
if (session || !shouldAutoAuthenticate()) {
setState(prev => ({ ...prev, autoAuthError: null, shouldTriggerAutoAuth: false }))
}
}, [session, shouldAutoAuthenticate])
return {
isAutoAuthenticating: state.isAutoAuthenticating,
autoAuthError: state.autoAuthError,
shouldTriggerAutoAuth: state.shouldTriggerAutoAuth,
triggerAnonymousAuth
}
}
@@ -0,0 +1,7 @@
import { Avatar } from '@mantine/core';
import { useAuth } from '@app/auth/UseSession';
export function useConfigButtonIcon(): React.ReactNode {
const { profilePictureUrl } = useAuth();
return profilePictureUrl ? <Avatar src={profilePictureUrl} radius="xl" size={24} /> : null;
}
+41
View File
@@ -0,0 +1,41 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useCredits } from '@app/hooks/useCredits';
import { getToolCreditCost } from '@app/utils/creditCosts';
import { openPlanSettings } from '@app/utils/appSettings';
import type { ToolId } from '@app/types/toolId';
export function useCreditCheck(operationType?: string) {
const { hasSufficientCredits, isPro, creditBalance, refreshCredits } = useCredits();
const { t } = useTranslation();
const checkCredits = useCallback(async (): Promise<string | null> => {
const requiredCredits = getToolCreditCost(operationType as ToolId);
const creditCheck = hasSufficientCredits(requiredCredits);
if (creditBalance === null) {
try { await refreshCredits(); } catch (_e) { void _e; }
return t('loadingCredits', 'Checking credits...');
}
if (isPro === null) {
return t('loadingProStatus', 'Checking subscription status...');
}
if (!isPro && !creditCheck.hasSufficientCredits) {
const shortfall = creditCheck.shortfall || 0;
const error = t(
'insufficientCredits',
'Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}',
{ requiredCredits, currentBalance: creditCheck.currentBalance, shortfall },
);
const notice = t('noticeTopUpOrPlan', 'Not enough credits, please top up or upgrade to a plan');
openPlanSettings(notice);
return error;
}
return null;
}, [hasSufficientCredits, isPro, creditBalance, refreshCredits, operationType, t]);
return { checkCredits };
}
+47
View File
@@ -0,0 +1,47 @@
import { useAuth } from '@app/auth/UseSession'
/**
* Hook for credit management and checking in tools.
* Provides easy access to credit balance, subscription info, and validation functions.
*/
export const useCredits = () => {
const {
creditBalance,
subscription,
creditSummary,
isPro,
hasSufficientCredits,
updateCredits,
refreshCredits
} = useAuth()
/**
* Get user-friendly credit status message
*/
const getCreditStatusMessage = (): string => {
if (creditBalance === 0) {
return 'No credits remaining'
}
if (creditBalance === null) {
return 'Credits loading...'
}
return `${creditBalance} credits available`
}
return {
// State
creditBalance,
subscription,
creditSummary,
isPro,
// Actions
refreshCredits,
updateCredits,
// Utilities
getCreditStatusMessage,
hasSufficientCredits
}
}
@@ -0,0 +1,207 @@
import { useState, useEffect } from 'react';
import apiClient from '@app/services/apiClient';
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
// Track globally fetched endpoint sets to prevent duplicate fetches across components
const globalFetchedSets = new Set<string>();
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
/**
* Hook to check if a specific endpoint is enabled
* This wraps the context for single endpoint checks
*/
export function useEndpointEnabled(endpoint: string): {
enabled: boolean | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
} {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchEndpointStatus = async () => {
if (!endpoint) {
setEnabled(null);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const response = await apiClient.get<boolean>(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`);
const isEnabled = response.data;
setEnabled(isEnabled);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchEndpointStatus();
}, [endpoint]);
return {
enabled,
loading,
error,
refetch: fetchEndpointStatus,
};
}
/**
* Hook to check multiple endpoints at once using batch API
* Returns a map of endpoint -> enabled status
*/
export function useMultipleEndpointsEnabled(endpoints: string[]): {
endpointStatus: Record<string, boolean>;
endpointDetails: Record<string, EndpointAvailabilityDetails>;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
} {
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchAllEndpointStatuses = async (force = false) => {
const endpointsKey = [...endpoints].sort().join(',');
// Skip if we already fetched these exact endpoints globally
if (!force && globalFetchedSets.has(endpointsKey)) {
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
const cached = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
if (cachedDetails) {
acc.status[endpoint] = cachedDetails.enabled;
acc.details[endpoint] = cachedDetails;
}
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(cached.status);
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
setLoading(false);
return;
}
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setEndpointDetails({});
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
// Check which endpoints we haven't fetched yet
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
if (newEndpoints.length === 0) {
console.debug('[useEndpointConfig] All endpoints already in global cache');
const cached = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
if (cachedDetails) {
acc.status[endpoint] = cachedDetails.enabled;
acc.details[endpoint] = cachedDetails;
}
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(cached.status);
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
globalFetchedSets.add(endpointsKey);
setLoading(false);
return;
}
// Use batch API for efficiency - only fetch new endpoints
const endpointsParam = newEndpoints.join(',');
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`);
const statusMap = response.data;
// Update global cache with new results
Object.entries(statusMap).forEach(([endpoint, details]) => {
globalEndpointCache[endpoint] = {
enabled: details?.enabled ?? true,
reason: details?.reason ?? null,
};
});
// Get all requested endpoints from cache (including previously cached ones)
const fullStatus = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
if (cachedDetails) {
acc.status[endpoint] = cachedDetails.enabled;
acc.details[endpoint] = cachedDetails;
} else {
acc.status[endpoint] = true;
}
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(fullStatus.status);
setEndpointDetails(prev => ({ ...prev, ...fullStatus.details }));
globalFetchedSets.add(endpointsKey);
} catch (err: any) {
// On 401 (auth error), use optimistic fallback instead of disabling
if (err.response?.status === 401) {
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
const optimisticStatus = endpoints.reduce(
(acc, endpoint) => {
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
acc.status[endpoint] = true;
acc.details[endpoint] = optimisticDetails;
globalEndpointCache[endpoint] = optimisticDetails;
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(optimisticStatus.status);
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
setLoading(false);
return;
}
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
// Fallback: assume all endpoints are enabled on error (optimistic)
const optimisticStatus = endpoints.reduce(
(acc, endpoint) => {
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
acc.status[endpoint] = true;
acc.details[endpoint] = optimisticDetails;
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(optimisticStatus.status);
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAllEndpointStatuses();
}, [endpoints.join(',')]); // Re-run when endpoints array changes
return {
endpointStatus,
endpointDetails,
loading,
error,
refetch: () => fetchAllEndpointStatuses(true),
};
}
@@ -0,0 +1,7 @@
/**
* SaaS no-op: Supabase handles auth, no JWT event listener needed,
* and config is fetched on all pages (401 handling covers unauthenticated state).
*/
export function useJwtConfigSync(_fetchConfig: (force?: boolean) => void): { isAuthPage: boolean } {
return { isAuthPage: false };
}
+285
View File
@@ -0,0 +1,285 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { supabase } from '@app/auth/supabase';
import { useAuth } from '@app/auth/UseSession';
// Currency mapping
const getCurrencySymbol = (currency: string): string => {
const currencySymbols: { [key: string]: string } = {
'gbp': '£',
'usd': '$',
'eur': '€',
'cny': '¥',
'inr': '₹',
'brl': 'R$',
'idr': 'Rp'
};
return currencySymbols[currency.toLowerCase()] || currency.toUpperCase();
};
export interface PlanFeature {
name: string;
included: boolean;
}
export interface PlanTier {
id: string;
name: string;
price: number;
currency: string;
period: string;
popular?: boolean;
features: PlanFeature[];
highlights: string[];
isContactOnly?: boolean;
}
export interface ApiPackage {
id: string;
name: string;
price: number;
currency: string;
credits: number;
description: string;
}
export interface PlansData {
plans: Map<string, PlanTier>;
apiPackages: ApiPackage[];
currentPlan: PlanTier;
nextBillingDate?: string;
activeSince?: string;
}
export const usePlans = (currency: string = 'gbp') => {
const { t } = useTranslation();
const { isPro, refreshProStatus } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPlanId, setCurrentPlanId] = useState<string>('free');
const [dynamicPrices, setDynamicPrices] = useState<Map<string, { unit_amount: number; currency: string }>>(new Map());
const fetchPricing = async () => {
try {
setLoading(true);
setError(null);
const lookupKeys = ['plan:pro', 'api:xsmall', 'api:small', 'api:medium', 'api:large'];
const { data, error } = await supabase.functions.invoke<{
prices: Record<string, { unit_amount: number; currency: string }>;
missing: string[];
}>('stripe-price-lookup', {
body: { lookup_keys: lookupKeys, currency }
});
if (error) throw error;
if (!data || !data.prices || !data.missing) throw new Error('No pricing data returned');
console.log('Fetched pricing data:', data);
const priceMap = new Map<string, { unit_amount: number; currency: string }>();
// map your UI keys to lookup keys (if names differ)
const keyMap: Record<string, string> = {
pro: 'plan:pro',
xsmall: 'api:xsmall',
small: 'api:small',
medium: 'api:medium',
large: 'api:large',
};
for (const [uiKey, lookupKey] of Object.entries(keyMap)) {
const p = data?.prices?.[lookupKey];
if (p) {
priceMap.set(uiKey, {
unit_amount: p.unit_amount ?? 0,
currency: p.currency
});
}
}
if (data.missing.length) {
console.warn('Missing prices for', data.missing, 'in', currency);
// Optionally re-request with a fallback currency (e.g., 'usd')
}
setDynamicPrices(priceMap);
} catch (err) {
console.error('Error fetching pricing:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch pricing data');
// continue with static prices if needed
} finally {
setLoading(false);
}
};
// Memoize static plan and package data to prevent recreation on every render
const staticPlansData = useMemo(() => {
const plans: PlanTier[] = [
{
id: 'free',
name: t('plan.free.name', 'Free'),
price: 0,
currency: getCurrencySymbol(currency),
period: t('plan.period.month', '/month'),
highlights: [
t('plan.free.highlight1', "Limited Tool Usage Per week"),
t('plan.free.highlight2', "Access to all tools"),
t('plan.free.highlight3', 'Community support')
],
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: false },
{ name: t('plan.feature.automation', 'automate tool workflows'), included: false },
{ name: t('plan.feature.api', 'API Access'), included: false },
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: false },
]
},
{
id: 'pro',
name: t('plan.pro.name', 'Pro'),
price: dynamicPrices.get('pro') ? dynamicPrices.get('pro')!.unit_amount / 100 : 8,
currency: dynamicPrices.get('pro') ? getCurrencySymbol(dynamicPrices.get('pro')!.currency) : getCurrencySymbol(currency),
period: t('plan.period.month', '/month'),
popular: true,
highlights: [
t('plan.pro.highlight1', 'Unlimited Tool Usage'),
t('plan.pro.highlight2', 'Advanced PDF tools'),
t('plan.pro.highlight3', 'No watermarks')
],
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
{ name: t('plan.feature.automation', 'automate tool workflows'), included: true },
{ name: t('plan.feature.api', 'Weekly API Credits'), included: true },
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: false },
]
},
{
id: 'enterprise',
name: t('plan.enterprise.name', 'Enterprise'),
price: 0,
currency: getCurrencySymbol(currency),
period: '',
isContactOnly: true,
highlights: [
t('plan.enterprise.highlight1', 'Custom pricing'),
t('plan.enterprise.highlight2', 'Dedicated support'),
t('plan.enterprise.highlight3', 'Latest features')
],
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
{ name: t('plan.feature.automation', 'automate tool workflows'), included: true },
{ name: t('plan.feature.api', 'Weekly API Credits'), included: true },
{ name: t('plan.feature.priority', 'Priority Support'), included: true },
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: true },
]
}
];
// Helper function to get price info
const getPriceInfo = (key: string, fallbackPrice: number) => {
const priceObj = dynamicPrices.get(key);
const price = priceObj ? priceObj.unit_amount / 100 : fallbackPrice;
const currencySymbol = priceObj ? getCurrencySymbol(priceObj.currency) : getCurrencySymbol(currency);
return { price, currencySymbol };
};
const xsmallPrice = getPriceInfo('xsmall', 4);
const smallPrice = getPriceInfo('small', 15);
const mediumPrice = getPriceInfo('medium', 25);
const largePrice = getPriceInfo('large', 90);
// Calculate dynamic discounts based on per-credit cost (using xsmall as baseline)
const xsmallPerCredit = xsmallPrice.price / 100;
const smallPerCredit = smallPrice.price / 500;
const mediumPerCredit = mediumPrice.price / 1000;
const largePerCredit = largePrice.price / 5000;
const smallDiscount = Math.round((1 - smallPerCredit / xsmallPerCredit) * 100);
const mediumDiscount = Math.round((1 - mediumPerCredit / xsmallPerCredit) * 100);
const largeDiscount = Math.round((1 - largePerCredit / xsmallPerCredit) * 100);
const apiPackages: ApiPackage[] = [
{
id: 'xsmall',
name: t('plan.api.xsmall', '100 Credits'),
price: xsmallPrice.price,
currency: xsmallPrice.currencySymbol,
credits: 100,
description: `${xsmallPrice.currencySymbol}${(xsmallPrice.price / 100).toFixed(3)} per credit`
},
{
id: 'small',
name: t('plan.api.small', '500 Credits'),
price: smallPrice.price,
currency: smallPrice.currencySymbol,
credits: 500,
description: `${smallPrice.currencySymbol}${(smallPrice.price / 500).toFixed(3)} per credit${smallDiscount > 0 ? `${smallDiscount}% discount` : ''}`
},
{
id: 'medium',
name: t('plan.api.medium', '1,000 Credits'),
price: mediumPrice.price,
currency: mediumPrice.currencySymbol,
credits: 1000,
description: `${mediumPrice.currencySymbol}${(mediumPrice.price / 1000).toFixed(3)} per credit${mediumDiscount > 0 ? `${mediumDiscount}% discount` : ''}`
},
{
id: 'large',
name: t('plan.api.large', '5,000 Credits'),
price: largePrice.price,
currency: largePrice.currencySymbol,
credits: 5000,
description: `${largePrice.currencySymbol}${(largePrice.price / 5000).toFixed(3)} per credit${largeDiscount > 0 ? `${largeDiscount}% discount` : ''}`
}
];
const plansMap = new Map(plans.map(plan => [plan.id, plan]));
return { plans: plansMap, apiPackages };
}, [t, dynamicPrices]);
// Create final data object with current plan info
const data = useMemo<PlansData | null>(() => {
if (!staticPlansData) return null;
const currentPlan = staticPlansData.plans.get(currentPlanId);
if (!currentPlan) return null;
return {
plans: staticPlansData.plans,
apiPackages: staticPlansData.apiPackages,
currentPlan,
nextBillingDate: 'Feb 15, 2025',
activeSince: 'January 2025'
};
}, [staticPlansData, currentPlanId]);
// Update currentPlanId when isPro changes
useEffect(() => {
if (isPro !== null) {
setCurrentPlanId(isPro ? 'pro' : 'free');
}
}, [isPro]);
// Initial load - fetch pricing data
useEffect(() => {
fetchPricing();
}, [currency]); // Re-fetch when currency changes
const updateCurrentPlan = (newPlanId: string) => {
setCurrentPlanId(newPlanId);
};
return {
data,
plans: data ? Array.from(data.plans.values()) : [], // Convert Map to array for compatibility with proprietary code
loading,
error,
refetch: refreshProStatus, // Refetch pro status from auth context
updateCurrentPlan // Add local plan update function
};
};
+217
View File
@@ -0,0 +1,217 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { supabase } from '@app/auth/supabase'
import { Button } from '@mantine/core'
import { withBasePath } from '@app/constants/app'
interface CallbackState {
status: 'processing' | 'success' | 'error'
message: string
details?: Record<string, any>
}
export default function AuthCallback() {
const navigate = useNavigate()
const [state, setState] = useState<CallbackState>({
status: 'processing',
message: 'Processing authentication...'
})
useEffect(() => {
const handleCallback = async () => {
try {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const error = url.searchParams.get('error')
const errorDescription = url.searchParams.get('error_description')
const next = url.searchParams.get('next') || '/'
console.log('[Auth Callback Debug] URL parameters:', {
hasCode: !!code,
hasError: !!error,
error,
errorDescription,
next,
fullUrl: window.location.href
})
// Handle OAuth errors
if (error) {
const errorMsg = errorDescription || error
console.error('[Auth Callback Debug] OAuth error:', { error, errorDescription })
setState({
status: 'error',
message: `Authentication failed: ${errorMsg}`,
details: { error, errorDescription }
})
// Redirect to login page after 3 seconds
setTimeout(() => navigate('/login', { replace: true }), 3000)
return
}
// If PKCE/SSR-style code is present, exchange it for a session
if (code) {
console.log('[Auth Callback Debug] Exchanging code for session...')
setState({
status: 'processing',
message: 'Exchanging authorization code...'
})
const { data, error: exchangeError } = await supabase.auth.exchangeCodeForSession(code)
if (exchangeError) {
console.error('[Auth Callback Debug] Code exchange error:', exchangeError)
setState({
status: 'error',
message: `Failed to complete sign in: ${exchangeError.message}`,
details: { exchangeError }
})
setTimeout(() => navigate('/login', { replace: true }), 3000)
return
}
console.log('[Auth Callback Debug] Code exchange successful:', {
hasSession: !!data.session,
userId: data.session?.user?.id,
email: data.session?.user?.email
})
setState({
status: 'success',
message: 'Sign in successful! Redirecting...',
details: {
userId: data.session?.user?.id,
email: data.session?.user?.email,
provider: data.session?.user?.app_metadata?.provider
}
})
} else {
// No code present - might already be authenticated
console.log('[Auth Callback Debug] No code present, checking existing session...')
const { data: sessionData } = await supabase.auth.getSession()
if (sessionData.session) {
console.log('[Auth Callback Debug] Existing session found')
setState({
status: 'success',
message: 'Already signed in! Redirecting...'
})
} else {
console.log('[Auth Callback Debug] No session found')
setState({
status: 'error',
message: 'No authentication data found'
})
setTimeout(() => navigate('/login', { replace: true }), 2000)
return
}
}
// Redirect to the intended destination
const destination = next.startsWith('/') ? next : '/'
console.log('[Auth Callback Debug] Redirecting to:', destination)
setTimeout(() => navigate(destination, { replace: true }), 1500)
} catch (err) {
console.error('[Auth Callback Debug] Unexpected error:', err)
setState({
status: 'error',
message: `Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`,
details: { error: err }
})
setTimeout(() => navigate('/login', { replace: true }), 3000)
}
}
handleCallback()
}, [navigate])
const getStatusColor = () => {
switch (state.status) {
case 'processing': return 'text-blue-600'
case 'success': return 'text-green-600'
case 'error': return 'text-red-600'
default: return 'text-gray-600'
}
}
const getTitle = () => {
switch (state.status) {
case 'processing': return 'Signing you in'
case 'success': return 'You\'re all set!'
case 'error': return 'Authentication failed'
default: return 'Authentication'
}
}
return (
<div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-slate-50 via-white to-slate-100">
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute -top-32 -left-32 h-96 w-96 rounded-full bg-blue-200/40 blur-3xl"></div>
<div className="absolute -bottom-32 -right-32 h-96 w-96 rounded-full bg-emerald-200/40 blur-3xl"></div>
</div>
<div className={`w-full max-w-md rounded-2xl bg-white/80 backdrop-blur shadow-xl p-8`}>
<div className="text-center">
<img
src={withBasePath("/branding/StirlingPDFLogoNoTextDark.svg")}
alt="Stirling PDF"
className="mx-auto mb-5 h-8 opacity-80"
/>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
{getTitle()}
</h1>
<p className={`text-base ${getStatusColor()}`}>
{state.message}
</p>
{state.status === 'processing' && (
<div className="mt-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<div className="mt-3 h-1 overflow-hidden rounded-full bg-slate-200">
<div className="h-full w-1/2 animate-pulse rounded-full bg-blue-500"></div>
</div>
</div>
)}
{/* Action button - only show if error */}
<div className="mt-6 flex items-center justify-center gap-3">
{(() => {
if (state.status === 'error') {
return (
<Button
onClick={() => navigate('/login', { replace: true })}
className="inline-flex items-center rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-500"
>
Back to login
</Button>
)
}
})()}
</div>
{import.meta.env.DEV && state.details && (
<details className="mt-6 text-left">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
Debug Information
</summary>
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
{JSON.stringify(state.details, null, 2)}
</pre>
</details>
)}
</div>
</div>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import React, { useMemo } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession'
import { useAutoAnonymousAuth } from '@app/hooks/useAutoAnonymousAuth'
import { isToolRoute } from '@app/utils/pathUtils'
import HomePage from '@app/pages/HomePage'
import Login from '@app/routes/Login'
import GuestUserBanner from '@app/components/auth/GuestUserBanner'
import { TrialStatusBanner } from '@app/components/shared/TrialStatusBanner'
export default function Landing() {
const { session, loading } = useAuth()
const { isAutoAuthenticating, autoAuthError, shouldTriggerAutoAuth } = useAutoAnonymousAuth()
const location = useLocation()
// Check if current path is a tool (prevents premature navigation on first render)
const isCurrentPathTool = useMemo(() => isToolRoute(location.pathname), [location.pathname])
// Match the same guarded bypass used in RequireAuth
const isLocalhost = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname)
const devBypassEnabled = Boolean(import.meta.env.DEV && isLocalhost && import.meta.env.VITE_DEV_BYPASS_AUTH === 'true')
console.log('[Landing] State:', {
pathname: location.pathname,
loading,
hasSession: !!session,
isAutoAuthenticating,
shouldTriggerAutoAuth,
isCurrentPathTool,
autoAuthError
})
// Show loading while checking auth, while auto-authenticating, OR while preparing to auto-authenticate
// CRITICAL: Also wait if shouldTriggerAutoAuth is true OR if we're on a tool route (prevents navigation before hook evaluates)
if (loading || isAutoAuthenticating || (!session && (shouldTriggerAutoAuth || isCurrentPathTool) && !autoAuthError)) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<div className="text-gray-600">
{isAutoAuthenticating ? 'Setting up your session...' : 'Loading...'}
</div>
</div>
</div>
)
}
// If we have a session or dev bypass is enabled, show the main app
if (session || devBypassEnabled) {
return (
<>
<GuestUserBanner />
<TrialStatusBanner />
<HomePage />
</>
)
}
// If auto-authentication failed, navigate to login with error state
if (autoAuthError && shouldTriggerAutoAuth) {
return <Navigate to="/login" replace state={{ autoAuthError, from: location }} />
}
// If we're at home route ("/"), show login directly (marketing/landing page)
// Otherwise navigate to login (fixes URL mismatch for tool routes)
const isHome = location.pathname === '/' || location.pathname === ''
if (isHome) {
return <Login />
}
// For non-home routes without auth, navigate to login (preserves from location)
return <Navigate to="/login" replace state={{ from: location }} />
}
+294
View File
@@ -0,0 +1,294 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { supabase, signInAnonymously } from '@app/auth/supabase'
import { useAuth } from '@app/auth/UseSession'
import { useTranslation } from '@app/hooks/useTranslation'
import { useDocumentMeta } from '@app/hooks/useDocumentMeta'
import AuthLayout from '@app/routes/authShared/AuthLayout'
import '@app/routes/authShared/auth.css'
import '@app/routes/authShared/saas-auth.css'
import GuestSignInButton from '@app/routes/authShared/GuestSignInButton'
// Import login components
import LoginHeader from '@app/routes/login/LoginHeader'
import ErrorMessage from '@app/routes/login/ErrorMessage'
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm'
import MagicLinkForm from '@app/routes/login/MagicLinkForm'
import OAuthButtons from '@app/routes/login/OAuthButtons'
import DividerWithText from '@app/components/shared/DividerWithText'
import LoggedInState from '@app/routes/login/LoggedInState'
import { absoluteWithBasePath, getBaseUrl } from '@app/constants/app'
export default function Login() {
const navigate = useNavigate()
const { session, loading, refreshSession } = useAuth()
const { t } = useTranslation()
const [isSigningIn, setIsSigningIn] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showMagicLink, setShowMagicLink] = useState(false)
const [showEmailForm, setShowEmailForm] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [magicLinkEmail, setMagicLinkEmail] = useState('')
// Prefill email from query param (e.g. after password reset)
useEffect(() => {
try {
const url = new URL(window.location.href)
const emailFromQuery = url.searchParams.get('email')
if (emailFromQuery) {
setEmail(emailFromQuery)
}
} catch (_) {
// ignore
}
}, [])
const baseUrl = getBaseUrl();
// Set document meta
useDocumentMeta({
title: `${t('login.title', 'Sign in')} - Stirling PDF`,
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogTitle: `${t('login.title', 'Sign in')} - Stirling PDF`,
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`
})
// Show logged in state if authenticated
if (session && !loading) {
return <LoggedInState />
}
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
try {
setIsSigningIn(true)
setError(null)
const redirectTo = absoluteWithBasePath('/auth/callback')
console.log(`[Login] Signing in with ${provider}`)
const oauthOptions: any = { redirectTo }
if (provider === 'apple') {
oauthOptions.queryParams = { scope: 'email name' }
} else if (provider === 'azure') {
oauthOptions.queryParams = { scope: 'openid profile email' }
} else {
oauthOptions.queryParams = {
access_type: 'offline',
prompt: 'consent',
}
}
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: oauthOptions
})
if (error) {
console.error(`[Login] ${provider} error:`, error)
setError(t('login.failedToSignIn', { provider, message: error.message }))
}
} catch (err) {
console.error(`[Login] Unexpected error:`, err)
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
} finally {
setIsSigningIn(false)
}
}
const signInWithEmail = async () => {
if (!email || !password) {
setError(t('login.pleaseEnterBoth'))
return
}
try {
setIsSigningIn(true)
setError(null)
console.log('[Login] Signing in with email:', email)
const { data, error } = await supabase.auth.signInWithPassword({
email: email.trim(),
password: password
})
if (error) {
console.error('[Login] Email sign in error:', error)
setError(error.message)
} else if (data.user) {
console.log('[Login] Email sign in successful')
// User will be redirected by the auth state change
}
} catch (err) {
console.error('[Login] Unexpected error]:', err)
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
} finally {
setIsSigningIn(false)
}
}
const signInWithMagicLink = async () => {
if (!magicLinkEmail) {
setError(t('login.pleaseEnterEmail'))
return
}
try {
setIsSigningIn(true)
setError(null)
console.log('[Login] Sending magic link to:', magicLinkEmail)
const { error } = await supabase.auth.signInWithOtp({
email: magicLinkEmail.trim(),
options: {
emailRedirectTo: absoluteWithBasePath('/auth/callback')
}
})
if (error) {
console.error('[Login] Magic link error:', error)
setError(error.message)
} else {
setError(null)
alert(t('login.magicLinkSent', { email: magicLinkEmail }))
setMagicLinkEmail('')
setShowMagicLink(false)
}
} catch (err) {
console.error('[Login] Unexpected error:', err)
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
} finally {
setIsSigningIn(false)
}
}
const handleForgotPassword = () => {
navigate('/auth/reset')
}
const handleAnonymousSignIn = async () => {
try {
setIsSigningIn(true)
setError(null)
console.log('[Login] Signing in anonymously')
const { data, error } = await signInAnonymously()
if (error) {
console.error('[Login] Anonymous sign in error:', error)
setError((error as any)?.message || 'Unknown error')
} else if (data.user) {
console.log('[Login] Anonymous sign in successful, refreshing session...')
// Refresh session to ensure backend endpoints are properly synchronized
await refreshSession()
console.log('[Login] Session refreshed, user will be redirected by auth state change')
// User will be redirected by the auth state change after session refresh
}
} catch (err) {
console.error('[Login] Unexpected error:', err)
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
} finally {
setIsSigningIn(false)
}
}
return (
<AuthLayout isEmailFormExpanded={showEmailForm}>
<LoginHeader title={t('login.login')} subtitle={t('login.subtitle', 'Sign back in to Stirling PDF')} />
<ErrorMessage error={error} />
{/* OAuth first */}
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="fullwidth"
/>
{/* Divider between OAuth and Email */}
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
{/* Sign in with email button (primary color to match signup CTA) */}
<div className="auth-section">
<button
type="button"
onClick={() => setShowEmailForm((v) => !v)}
disabled={isSigningIn}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t('login.useEmailInstead', 'Sign in with email')}
</button>
</div>
{showEmailForm && (
<EmailPasswordForm
email={email}
password={password}
setEmail={setEmail}
setPassword={setPassword}
onSubmit={signInWithEmail}
isSubmitting={isSigningIn}
submitButtonText={isSigningIn ? t('login.loggingIn') : t('login.login')}
/>
)}
{showEmailForm && (
<div className="auth-section-sm">
<button
type="button"
onClick={handleForgotPassword}
className="auth-link-black"
>
{t('login.forgotPassword', 'Forgot your password?')}
</button>
</div>
)}
{/* Divider then Guest */}
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
<GuestSignInButton
onClick={handleAnonymousSignIn}
disabled={isSigningIn}
label={isSigningIn ? t('login.signingIn', 'Signing in...') : t('login.signInAnonymously', 'Sign in as a Guest')}
/>
<div className="auth-bottom-row">
<button
type="button"
onClick={() => setShowMagicLink(true)}
className="auth-link-black"
>
{t('login.useMagicLink', 'Sign in with magic link')}
</button>
<button
type="button"
onClick={() => navigate('/signup')}
className="auth-link-black"
>
{t('signup.signUp', 'Sign up')}
</button>
</div>
{/* Magic link form renders on demand */}
{showMagicLink && (
<div style={{ marginTop: '0.5rem' }}>
<MagicLinkForm
showMagicLink={showMagicLink}
magicLinkEmail={magicLinkEmail}
setMagicLinkEmail={setMagicLinkEmail}
setShowMagicLink={setShowMagicLink}
onSubmit={signInWithMagicLink}
isSubmitting={isSigningIn}
/>
</div>
)}
</AuthLayout>
)
}
+240
View File
@@ -0,0 +1,240 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import AuthLayout from '@app/routes/authShared/AuthLayout'
import LoginHeader from '@app/routes/login/LoginHeader'
import ErrorMessage from '@app/routes/login/ErrorMessage'
import SuccessMessage from '@app/routes/login/SuccessMessage'
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm'
import NavigationLink from '@app/routes/login/NavigationLink'
import { supabase } from '@app/auth/supabase'
import { absoluteWithBasePath } from '@app/constants/app'
import { useTranslation } from '@app/hooks/useTranslation'
export default function ResetPassword() {
const { t } = useTranslation()
const navigate = useNavigate()
const [isSubmitting, setIsSubmitting] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [isRecovery, setIsRecovery] = useState(false)
const [didUpdate, setDidUpdate] = useState(false)
useEffect(() => {
const url = new URL(window.location.href)
const type = url.searchParams.get('type')
const code = url.searchParams.get('code')
// Also parse hash params (Supabase puts tokens & type in the hash)
const hash = url.hash || ''
const hashParams = new URLSearchParams(hash.startsWith('#') ? hash.substring(1) : hash)
const hashType = hashParams.get('type')
const hashError = hashParams.get('error')
const hashErrorDescription = hashParams.get('error_description')
if (hashError) {
// Show a human-readable error and fall back to email-entry form
setError(hashErrorDescription || hashError)
setIsRecovery(false)
}
// Consider either source (query or hash) to decide if we're in recovery mode
const inRecovery = type === 'recovery' || hashType === 'recovery'
setIsRecovery(inRecovery)
// If a PKCE-style code is present, exchange it for a session immediately
const tryExchange = async () => {
if (code) {
try {
const { data, error } = await supabase.auth.exchangeCodeForSession(code)
if (error) {
setError(error.message)
setIsRecovery(false)
} else if (data.session) {
setIsRecovery(true)
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setIsRecovery(false)
}
} else {
// If no code, see if Supabase already set the session from hash
const { data } = await supabase.auth.getSession()
if (data.session && inRecovery) {
setIsRecovery(true)
}
}
}
void tryExchange()
// Clear sensitive tokens from the URL hash
if (hash.includes('access_token') || hashError) {
window.history.replaceState({}, document.title, window.location.pathname + (inRecovery ? '?type=recovery' : ''))
}
// Listen for Supabase auth state changes to confirm recovery state
const { data: sub } = supabase.auth.onAuthStateChange((event) => {
if (event === 'PASSWORD_RECOVERY') {
setIsRecovery(true)
}
})
return () => {
sub.subscription.unsubscribe()
}
}, [])
const handleSendEmail = async () => {
if (!email) {
setError(t('login.pleaseEnterEmail'))
return
}
try {
setIsSubmitting(true)
setError(null)
const redirectTo = absoluteWithBasePath('/auth/reset?type=recovery')
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
redirectTo
})
if (error) {
setError(error.message)
} else {
setSuccess(t('login.passwordResetSent', { email }))
}
} finally {
setIsSubmitting(false)
}
}
const handleUpdatePassword = async () => {
if (!password || !confirmPassword) {
setError(t('signup.pleaseFillAllFields'))
return
}
if (password.length < 6) {
setError(t('signup.passwordTooShort'))
return
}
if (password !== confirmPassword) {
setError(t('signup.passwordsDoNotMatch'))
return
}
try {
setIsSubmitting(true)
setError(null)
const { data, error } = await supabase.auth.updateUser({ password })
if (error) {
setError(error.message)
return
}
if (data.user) {
setSuccess(t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.'))
// Clear the form fields
setPassword('')
setConfirmPassword('')
// Show success-only state and then redirect after a short delay
setDidUpdate(true)
setTimeout(async () => {
const { data: sessionData } = await supabase.auth.getSession()
const { data: userData } = await supabase.auth.getUser()
const derivedEmail = userData.user?.email || email
if (sessionData.session) {
navigate('/')
} else {
const query = derivedEmail ? `?email=${encodeURIComponent(derivedEmail)}` : ''
navigate(`/login${query}`)
}
}, 2000)
}
} finally {
setIsSubmitting(false)
}
}
return (
<AuthLayout>
<LoginHeader title={isRecovery ? t('login.resetYourPassword', 'Reset your password') : t('login.forgotPassword', 'Forgot your password?')} />
{!didUpdate && <SuccessMessage success={success} />}
<ErrorMessage error={error} />
{didUpdate ? (
<>
<SuccessMessage success={success || t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.')} />
<NavigationLink
onClick={() => navigate('/login')}
text={t('login.backToSignIn', 'Back to sign in')}
isDisabled={isSubmitting}
/>
</>
) : isRecovery ? (
<>
<div className="auth-fields">
<div className="auth-field">
<label htmlFor="password" className="auth-label">{t('signup.password')}</label>
<input
id="password"
type="password"
name="new-password"
autoComplete="new-password"
placeholder={t('signup.enterPassword')}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="auth-input"
/>
</div>
<div className="auth-field">
<label htmlFor="confirmPassword" className="auth-label">{t('signup.confirmPassword')}</label>
<input
id="confirmPassword"
type="password"
name="new-password"
autoComplete="new-password"
placeholder={t('signup.confirmPasswordPlaceholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="auth-input"
/>
</div>
</div>
<button
onClick={handleUpdatePassword}
disabled={isSubmitting || !password || !confirmPassword}
className="auth-button"
>
{isSubmitting ? t('login.sending', 'Sending…') : t('login.updatePassword', 'Update password')}
</button>
<NavigationLink
onClick={() => navigate('/login')}
text={t('login.backToSignIn', 'Back to sign in')}
isDisabled={isSubmitting}
/>
</>
) : (
<>
<EmailPasswordForm
email={email}
password={''}
setEmail={setEmail}
setPassword={() => {}}
onSubmit={handleSendEmail}
isSubmitting={isSubmitting}
submitButtonText={t('login.sendResetLink', 'Send reset link')}
showPasswordField={false}
/>
<p className="text-sm text-gray-500 mt-3">
{t('login.resetHelp', 'Enter your email to receive a secure link to reset your password. If the link has expired, please request a new one.')}
</p>
<NavigationLink
onClick={() => navigate('/login')}
text={t('login.backToSignIn', 'Back to sign in')}
isDisabled={isSubmitting}
/>
</>
)}
</AuthLayout>
)
}
+226
View File
@@ -0,0 +1,226 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { signInAnonymously } from '@app/auth/supabase'
import { useAuth } from '@app/auth/UseSession'
import { useTranslation } from '@app/hooks/useTranslation'
import { useDocumentMeta } from '@app/hooks/useDocumentMeta'
import { getBaseUrl } from '@app/constants/app'
import AuthLayout from '@app/routes/authShared/AuthLayout'
import '@app/routes/authShared/auth.css'
import '@app/routes/authShared/saas-auth.css'
import GuestSignInButton from '@app/routes/authShared/GuestSignInButton'
import { alert } from '@app/components/toast'
// Import signup components
import LoginHeader from '@app/routes/login/LoginHeader'
import ErrorMessage from '@app/routes/login/ErrorMessage'
import OAuthButtons from '@app/routes/login/OAuthButtons'
import DividerWithText from '@app/components/shared/DividerWithText'
import SignupForm from '@app/routes/signup/SignupForm'
import { useSignupFormValidation, SignupFieldErrors } from '@app/routes/signup/SignupFormValidation'
import { useAuthService } from '@app/routes/signup/AuthService'
export default function Signup() {
const navigate = useNavigate()
const location = useLocation()
const { session, loading, refreshSession } = useAuth()
const { t } = useTranslation()
const [isSigningUp, setIsSigningUp] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showEmailForm, setShowEmailForm] = useState(false)
const [name, setName] = useState(undefined as string | undefined)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [agree, setAgree] = useState(true)
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({})
// Check if we were redirected here with an auto-auth error
useEffect(() => {
const state = location.state as { autoAuthError?: string } | null
if (state?.autoAuthError) {
setError(`Unable to access tool: ${state.autoAuthError}`)
}
}, [location.state])
// Redirect back to original tool URL once session appears (after auto-anon completes)
useEffect(() => {
if (!loading && session) {
const state = location.state as { from?: { pathname?: string; search?: string; hash?: string } } | null
const from = state?.from
if (from?.pathname && from.pathname !== '/signup' && from.pathname !== '/login') {
const target = `${from.pathname}${from.search ?? ''}${from.hash ?? ''}`
console.log('[Signup] Session detected, redirecting back to:', target)
navigate(target, { replace: true })
}
}
}, [loading, session, location.state, navigate])
const handleAnonymousSignIn = async () => {
try {
setIsSigningUp(true)
setError(null)
console.log('[Signup] Initiating anonymous sign-in...')
const { data, error } = await signInAnonymously()
if (error) {
console.error('[Signup] Anonymous sign-in error:', error)
setError(`Failed to create guest account: ${(error as any)?.message || 'Unknown error'}`)
} else if (data.user) {
console.log('[Signup] Anonymous sign-in successful, refreshing session...')
// Refresh session to ensure backend endpoints are properly synchronized
await refreshSession()
console.log('[Signup] Session refreshed, redirecting to home page')
// Redirect to home page after successful anonymous login and session refresh
navigate('/')
}
} catch (err) {
console.error('[Signup] Anonymous sign-in unexpected error:', err)
setError(`Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
setIsSigningUp(false)
}
}
const baseUrl = getBaseUrl();
// Set document meta
useDocumentMeta({
title: `${t('signup.title', 'Create an account')} - Stirling PDF`,
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogTitle: `${t('signup.title', 'Create an account')} - Stirling PDF`,
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`
})
const { validateSignupForm } = useSignupFormValidation()
const { signUp, signInWithProvider } = useAuthService()
const handleSignUp = async () => {
const validation = validateSignupForm(email, password, confirmPassword, name)
if (!validation.isValid) {
setError(validation.error)
setFieldErrors(validation.fieldErrors || {})
return
}
try {
setIsSigningUp(true)
setError(null)
setFieldErrors({})
const result = await signUp(email, password, name)
if (result.requiresEmailConfirmation) {
alert({
alertType: 'success',
title: t('signup.checkEmailConfirmation'),
location: 'top-right',
isPersistentPopup: true
})
} else {
alert({
alertType: 'success',
title: t('signup.accountCreatedSuccessfully'),
location: 'top-right',
durationMs: 3000
})
setTimeout(() => navigate('/login'), 2000)
}
} catch (err) {
console.error('[Signup] Unexpected error:', err)
setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }))
} finally {
setIsSigningUp(false)
}
}
const handleProviderSignIn = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
try {
setIsSigningUp(true)
setError(null)
await signInWithProvider(provider)
} catch (err) {
setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }))
} finally {
setIsSigningUp(false)
}
}
return (
<AuthLayout isEmailFormExpanded={showEmailForm}>
<LoginHeader title={t('signup.title')} subtitle={t('signup.subtitle')} />
<ErrorMessage error={error} />
{/* OAuth first */}
<div style={{ marginBottom: '0.5rem' }}>
<OAuthButtons
onProviderClick={handleProviderSignIn}
isSubmitting={isSigningUp}
layout="fullwidth"
/>
</div>
{/* Divider between OAuth and Email */}
<div style={{ margin: '0.5rem 0' }}>
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
</div>
{/* Use Email Instead button (toggles email form) */}
<div className="auth-section">
<button
type="button"
disabled={isSigningUp}
onClick={() => setShowEmailForm((v) => !v)}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t('signup.useEmailInstead', 'Use Email Instead')}
</button>
</div>
{showEmailForm && (
<SignupForm
name={name}
email={email}
password={password}
confirmPassword={confirmPassword}
agree={agree}
setName={setName}
setEmail={setEmail}
setPassword={setPassword}
setConfirmPassword={setConfirmPassword}
setAgree={setAgree}
onSubmit={handleSignUp}
isSubmitting={isSigningUp}
fieldErrors={fieldErrors}
/>
)}
<div className="auth-section-sm">
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
</div>
<GuestSignInButton
onClick={handleAnonymousSignIn}
disabled={isSigningUp}
label={isSigningUp ? t('login.signingIn', 'Signing in...') : t('login.signInAnonymously', 'Sign in as a Guest')}
/>
{/* Bottom row */}
<div className="auth-bottom-right">
<button
type="button"
onClick={() => navigate('/login')}
className="auth-link-black"
>
{t('login.logIn', 'Log In')}
</button>
</div>
</AuthLayout>
)
}
@@ -0,0 +1,74 @@
.authContainer {
position: relative;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--auth-bg-color-light-only);
padding: 1.5rem 1.5rem 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow: auto;
}
/* Main area above footer: keep card centered even with footer visible */
.authMain {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 95vh;
}
.authCard {
width: min(45rem, 96vw);
height: min(50.875rem, 96vh);
display: grid;
grid-template-columns: 1fr;
background-color: var(--auth-card-bg);
border-radius: 1.25rem;
box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12);
overflow: hidden;
min-height: 0;
max-height: 96vh;
}
.authCardTwoColumns {
width: min(73.75rem, 96vw);
grid-template-columns: 1fr 1fr;
}
.authLeftPanel {
display: flex;
justify-content: center;
padding: 2rem;
min-height: 0;
height: 100%;
}
.authLeftPanelCentered {
align-items: center;
overflow: hidden;
}
.authLeftPanelScrollable {
align-items: flex-start;
overflow-y: auto;
overflow-x: hidden;
}
.authLeftPanel::-webkit-scrollbar {
display: none; /* WebKit browsers (Chrome, Safari, Edge) */
}
.authContent {
max-width: 26.25rem; /* 420px */
width: 100%;
display: flex;
flex-direction: column;
}
.authLeftPanelScrollable .authContent {
min-height: 100%;
}
@@ -0,0 +1,88 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import LoginRightCarousel from '@app/components/shared/LoginRightCarousel';
import buildLoginSlides from '@app/components/shared/loginSlides';
import styles from '@app/routes/authShared/AuthLayout.module.css';
import { useLogoVariant } from '@app/hooks/useLogoVariant';
import { useIsOverflowing } from '@app/hooks/useIsOverflowing';
import Footer from '@app/components/shared/Footer';
interface AuthLayoutProps {
children: React.ReactNode
isEmailFormExpanded?: boolean
}
export default function AuthLayout({ children, isEmailFormExpanded = false }: AuthLayoutProps) {
const { t } = useTranslation();
const cardRef = useRef<HTMLDivElement | null>(null);
const leftPanelRef = useRef<HTMLDivElement | null>(null);
const [hideRightPanel, setHideRightPanel] = useState(false);
const logoVariant = useLogoVariant();
const imageSlides = useMemo(() => buildLoginSlides(logoVariant, t), [logoVariant, t]);
const isOverflowing = useIsOverflowing(leftPanelRef);
// Use either overflow detection or email form expansion to determine scrollable state
const shouldBeScrollable = isOverflowing || isEmailFormExpanded;
// Force light mode on auth pages
useEffect(() => {
const htmlElement = document.documentElement;
const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme');
// Set light mode
htmlElement.setAttribute('data-mantine-color-scheme', 'light');
// Cleanup: restore previous theme when leaving auth pages
return () => {
if (previousColorScheme) {
htmlElement.setAttribute('data-mantine-color-scheme', previousColorScheme);
}
};
}, []);
useEffect(() => {
const update = () => {
// Use viewport to avoid hysteresis when the card is already in single-column mode
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
const columnWidth = cardWidthIfTwoCols / 2;
const tooNarrow = columnWidth < 470;
const tooShort = viewportHeight < 740;
setHideRightPanel(tooNarrow || tooShort);
};
update();
window.addEventListener('resize', update);
window.addEventListener('orientationchange', update);
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('orientationchange', update);
};
}, []);
return (
<div className={styles.authContainer}>
<div className={styles.authMain}>
<div
ref={cardRef}
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ''}`}
>
<div
ref={leftPanelRef}
className={`${styles.authLeftPanel} ${shouldBeScrollable ? styles.authLeftPanelScrollable : styles.authLeftPanelCentered}`}
>
<div className={styles.authContent}>
{children}
</div>
</div>
{!hideRightPanel && (
<LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />
)}
</div>
</div>
<div style={{ width: '100vw', marginTop: 'auto', marginLeft: '-1.5rem', marginRight: '-1.5rem' }}>
<Footer forceLightMode={true} analyticsEnabled />
</div>
</div>
)
}
@@ -0,0 +1,24 @@
import React from 'react'
import '@app/routes/authShared/auth.css'
import '@app/routes/authShared/saas-auth.css'
interface GuestSignInButtonProps {
label: string
onClick: () => void
disabled?: boolean
}
export default function GuestSignInButton({ label, onClick, disabled }: GuestSignInButtonProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed auth-guest-button"
>
{label}
</button>
)
}
@@ -0,0 +1,72 @@
/* SaaS-specific auth styles — imported alongside the base auth.css */
.oauth-container-fullwidth {
display: flex;
flex-direction: column;
gap: 0.75rem; /* 12px */
margin-bottom: 0.625rem; /* 10px */
}
.oauth-button-fullwidth {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.625rem;
background-color: #ffffff;
font-size: 1rem;
font-weight: 600;
color: #000000;
cursor: pointer;
gap: 0.5rem;
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04);
}
.oauth-button-fullwidth:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.auth-dropdown-wrapper {
position: relative;
}
.auth-dropdown-backdrop {
position: fixed;
inset: 0;
background: transparent;
z-index: 30;
}
.auth-dropdown {
position: absolute;
z-index: 40;
margin-top: 0.5rem;
min-width: 16rem;
background-color: #ffffff;
color: #000000;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
padding: 0.25rem;
}
.auth-dropdown-item {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: none;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
color: #000;
cursor: pointer;
}
.auth-guest-button {
background-color: #ffffff;
color: #9c2f30;
border: 2px solid currentColor;
}
@@ -0,0 +1,86 @@
import { useTranslation } from 'react-i18next';
import '@app/routes/authShared/auth.css';
import '@app/routes/authShared/saas-auth.css';
interface EmailPasswordFormProps {
email: string
password: string
setEmail: (email: string) => void
setPassword: (password: string) => void
onSubmit: () => void
isSubmitting: boolean
submitButtonText: string
showPasswordField?: boolean
fieldErrors?: {
email?: string
password?: string
}
}
export default function EmailPasswordForm({
email,
password,
setEmail,
setPassword,
onSubmit,
isSubmitting,
submitButtonText,
showPasswordField = true,
fieldErrors = {}
}: EmailPasswordFormProps) {
const { t } = useTranslation();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit();
};
return (
<form onSubmit={handleSubmit}>
<div className="auth-fields">
<div className="auth-field">
<label htmlFor="email" className="auth-label">{t('login.email', 'Email')}</label>
<input
id="email"
type="email"
name="email"
autoComplete="username email"
placeholder={t('login.enterEmail', 'Enter email')}
value={email}
onChange={(e) => setEmail(e.target.value)}
className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`}
/>
{fieldErrors.email && (
<div className="auth-field-error">{fieldErrors.email}</div>
)}
</div>
{showPasswordField && (
<div className="auth-field">
<label htmlFor="password" className="auth-label">{t('login.password')}</label>
<input
id="password"
type="password"
name="current-password"
autoComplete="current-password"
placeholder={t('login.enterPassword')}
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`}
/>
{fieldErrors.password && (
<div className="auth-field-error">{fieldErrors.password}</div>
)}
</div>
)}
</div>
<button
type="submit"
disabled={isSubmitting || !email || (showPasswordField && !password)}
className="auth-button"
>
{submitButtonText}
</button>
</form>
);
}
@@ -0,0 +1,20 @@
import { useTranslation } from '@app/hooks/useTranslation'
export default function LoadingState() {
const { t } = useTranslation()
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f3f4f6'
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '32px', marginBottom: '16px' }}></div>
<p style={{ color: '#6b7280' }}>{t('loading')}</p>
</div>
</div>
)
}
@@ -0,0 +1,57 @@
import { useTranslation } from '@app/hooks/useTranslation'
import '@app/routes/authShared/auth.css'
import '@app/routes/authShared/saas-auth.css'
interface MagicLinkFormProps {
showMagicLink: boolean
magicLinkEmail: string
setMagicLinkEmail: (email: string) => void
setShowMagicLink: (show: boolean) => void
onSubmit: () => void
isSubmitting: boolean
}
export default function MagicLinkForm({
showMagicLink,
magicLinkEmail,
setMagicLinkEmail,
setShowMagicLink,
onSubmit,
isSubmitting
}: MagicLinkFormProps) {
const { t } = useTranslation()
if (!showMagicLink) {
return (
<div className="auth-toggle-wrapper">
<button
onClick={() => { setShowMagicLink(true) }}
disabled={isSubmitting}
className="auth-toggle-link"
>
{t('login.useMagicLink')}
</button>
</div>
)
}
return (
<div className="auth-magic-row">
<input
type="email"
placeholder={t('login.enterEmailForMagicLink')}
value={magicLinkEmail}
onChange={(e) => setMagicLinkEmail(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
className="auth-input"
/>
<button
onClick={onSubmit}
disabled={isSubmitting || !magicLinkEmail}
className="auth-magic-button"
>
{isSubmitting ? t('login.sending') : t('login.sendMagicLink')}
</button>
</div>
)
}
@@ -0,0 +1,106 @@
import { oauthProviders } from '@app/constants/authProviders'
import { useTranslation } from '@app/hooks/useTranslation'
import { Tooltip } from '@app/components/shared/Tooltip'
import { withBasePath } from '@app/constants/app'
// Exports for compatibility with proprietary code
export const DEBUG_SHOW_ALL_PROVIDERS = false;
export const oauthProviderConfig = {
google: { label: 'Google', file: 'google.svg' },
github: { label: 'GitHub', file: 'github.svg' },
apple: { label: 'Apple', file: 'apple.svg' },
azure: { label: 'Microsoft', file: 'microsoft.svg' },
};
interface OAuthButtonsProps {
onProviderClick: (provider: 'github' | 'google') => void
isSubmitting: boolean
layout?: 'vertical' | 'grid' | 'icons' | 'fullwidth'
enabledProviders?: string[] // List of enabled provider IDs from backend
}
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders: _enabledProviders = [] }: OAuthButtonsProps) {
const { t } = useTranslation()
if (layout === 'icons') {
return (
<div className="oauth-container-icons">
{oauthProviders.map((p) => (
<Tooltip
key={p.id}
content={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
position="top"
>
<button
onClick={() => onProviderClick(p.id as any)}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-icon"
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
>
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-small ${p.isDisabled ? 'opacity-20' : ''}`}/>
</button>
</Tooltip>
))}
</div>
)
}
if (layout === 'grid') {
return (
<div className="oauth-container-grid">
{oauthProviders.map((p) => (
<Tooltip
key={p.id}
content={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
position="top"
>
<button
onClick={() => onProviderClick(p.id as any)}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-grid"
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
>
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-medium ${p.isDisabled ? 'opacity-20' : ''}`}/>
</button>
</Tooltip>
))}
</div>
)
}
if (layout === 'fullwidth') {
return (
<div className="oauth-container-fullwidth">
{oauthProviders.map((p) => (
<button
key={p.id}
onClick={() => onProviderClick(p.id as any)}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-fullwidth"
title={p.label}
>
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-medium ${p.isDisabled ? 'opacity-20' : ''}`} />
{p.label}
</button>
))}
</div>
)
}
return (
<div className="oauth-container-vertical">
{oauthProviders.map((p) => (
<button
key={p.id}
onClick={() => onProviderClick(p.id as any)}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-vertical"
title={p.label}
>
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-tiny ${p.isDisabled ? 'opacity-20' : ''}`} />
{p.label}
</button>
))}
</div>
)
}
@@ -0,0 +1,13 @@
interface SuccessMessageProps {
success: string | null
}
export default function SuccessMessage({ success }: SuccessMessageProps) {
if (!success) return null
return (
<div className="success-message">
<p className="success-message-text">{success}</p>
</div>
)
}
@@ -0,0 +1,54 @@
import { supabase } from '@app/auth/supabase'
import { absoluteWithBasePath } from '@app/constants/app'
export const useAuthService = () => {
const signUp = async (
email: string,
password: string,
name?: string
) => {
console.log('[Signup] Creating account for:', email)
const { data, error } = await supabase.auth.signUp({
email: email.trim(),
password: password,
options: {
emailRedirectTo: absoluteWithBasePath('/auth/callback'),
data: { full_name: name }
}
})
if (error) {
console.error('[Signup] Sign up error:', error)
throw new Error(error.message)
}
if (data.user) {
console.log('[Signup] Sign up successful:', data.user)
return {
user: data.user,
session: data.session,
requiresEmailConfirmation: data.user && !data.session
}
}
throw new Error('Unknown error occurred during signup')
}
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: { redirectTo: absoluteWithBasePath('/auth/callback') }
})
if (error) {
throw new Error(error.message)
}
}
return {
signUp,
signInWithProvider
}
}
@@ -0,0 +1,26 @@
import { supabase } from '@app/auth/supabase';
interface DeleteAccountOptions {
notifyUser?: boolean;
}
interface DeleteUserResponse {
success?: boolean;
error?: string;
deleted_supabase_id?: string;
stripe_redaction_job_id?: string | null;
}
export async function deleteCurrentAccount(options?: DeleteAccountOptions): Promise<void> {
const { data, error } = await supabase.functions.invoke<DeleteUserResponse>('delete-user', {
body: {
notify_user: options?.notifyUser ?? true,
},
});
if (error || !data?.success) {
const serverMessage = data?.error;
const errorMessage = serverMessage || error?.message || 'Failed to delete account';
throw new Error(errorMessage);
}
}
@@ -0,0 +1,237 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { supabase } from '@app/auth/supabase';
// Mock supabase
vi.mock('@app/auth/supabase', () => ({
supabase: {
auth: {
getSession: vi.fn(),
refreshSession: vi.fn(),
},
},
}));
describe('apiClient', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset modules to get fresh instance of apiClient
vi.resetModules();
});
it('should add JWT token to request headers when session exists', async () => {
const mockToken = 'test-jwt-token-12345';
const mockSession: any = {
access_token: mockToken,
refresh_token: 'refresh-token',
expires_in: 3600,
token_type: 'bearer',
user: { id: 'user-123' },
};
// Mock getSession to return a session with token
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: mockSession },
error: null,
});
// Import apiClient after mocking
const { default: apiClient } = await import('@app/services/apiClient');
// Create a mock adapter to intercept the request
const mockAdapter = vi.fn((config) => {
// Verify the Authorization header is set correctly
expect(config.headers.Authorization).toBe(`Bearer ${mockToken}`);
return Promise.resolve({
data: { success: true },
status: 200,
statusText: 'OK',
headers: {},
config,
});
});
// Replace the adapter
apiClient.defaults.adapter = mockAdapter;
// Make a test request
await apiClient.get('/api/v1/test');
// Verify the request was made with the token
expect(mockAdapter).toHaveBeenCalled();
expect(supabase.auth.getSession).toHaveBeenCalled();
});
it('should handle requests when no session exists', async () => {
// Mock getSession to return no session
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: null },
error: null,
});
// Import apiClient after mocking
const { default: apiClient } = await import('@app/services/apiClient');
// Create a mock adapter to intercept the request
const mockAdapter = vi.fn((config) => {
// Verify no Authorization header is set
expect(config.headers.Authorization).toBeUndefined();
return Promise.resolve({
data: { success: true },
status: 200,
statusText: 'OK',
headers: {},
config,
});
});
// Replace the adapter
apiClient.defaults.adapter = mockAdapter;
// Make a test request
await apiClient.get('/api/v1/test');
// Verify the request was made without a token
expect(mockAdapter).toHaveBeenCalled();
expect(supabase.auth.getSession).toHaveBeenCalled();
});
it('should refresh token on 401 response', async () => {
const oldToken = 'old-token';
const newToken = 'new-refreshed-token';
const oldSession: any = {
access_token: oldToken,
refresh_token: 'refresh-token',
expires_in: 3600,
token_type: 'bearer',
user: { id: 'user-123' },
};
const newSession: any = {
access_token: newToken,
refresh_token: 'new-refresh-token',
expires_in: 3600,
token_type: 'bearer',
user: { id: 'user-123' },
};
// Mock initial session for first request
let getSessionCallCount = 0;
vi.mocked(supabase.auth.getSession).mockImplementation(async () => {
getSessionCallCount++;
// First call returns old session, subsequent calls return new session
if (getSessionCallCount === 1) {
return { data: { session: oldSession }, error: null };
}
return { data: { session: newSession }, error: null };
});
// Mock refresh to return new session
vi.mocked(supabase.auth.refreshSession).mockResolvedValue({
data: { user: null, session: newSession },
error: null as any,
} as any);
// Import apiClient after mocking
const { default: apiClient } = await import('@app/services/apiClient');
let requestCount = 0;
const mockAdapter = vi.fn((config) => {
requestCount++;
// First request returns 401
if (requestCount === 1) {
// Verify first request has old token
expect(config.headers.Authorization).toBe(`Bearer ${oldToken}`);
const error: any = new Error('Unauthorized');
error.response = {
status: 401,
data: { error: 'Unauthorized' },
};
error.config = config;
return Promise.reject(error);
}
// Second request (after refresh) should have new token
// The interceptor will call getSession again, which now returns the new session
expect(config.headers.Authorization).toBe(`Bearer ${newToken}`);
return Promise.resolve({
data: { success: true },
status: 200,
statusText: 'OK',
headers: {},
config,
});
});
// Replace the adapter
apiClient.defaults.adapter = mockAdapter;
// Make a test request that will trigger 401 and retry
const response = await apiClient.get('/api/v1/test');
// Verify the token was refreshed and request retried
expect(response.data).toEqual({ success: true });
expect(supabase.auth.refreshSession).toHaveBeenCalled();
expect(mockAdapter).toHaveBeenCalledTimes(2);
expect(getSessionCallCount).toBe(3); // Called for initial request, for checking if refresh is possible, and for retry
});
it('should handle refresh token failure', async () => {
const oldToken = 'old-token';
const oldSession = {
access_token: oldToken,
user: { id: 'user-123' },
};
// Mock initial session
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: oldSession },
error: null,
} as any);
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: oldSession },
error: null,
} as any);
// Mock refresh to fail
vi.mocked(supabase.auth.refreshSession).mockResolvedValue({
data: { user: null, session: null },
error: { name: 'AuthError', message: 'Refresh failed', status: 400, code: 'auth_error', __isAuthError: true } as any,
} as any);
// Import apiClient after mocking
const { default: apiClient } = await import('@app/services/apiClient');
// Mock window.location for redirect test
delete (window as any).location;
window.location = { href: '' } as any;
const mockAdapter = vi.fn((config) => {
// Always return 401 to trigger refresh
const error: any = new Error('Unauthorized');
error.response = {
status: 401,
data: { error: 'Unauthorized' },
};
error.config = config;
return Promise.reject(error);
});
// Replace the adapter
apiClient.defaults.adapter = mockAdapter;
// Make a test request that will trigger 401
try {
await apiClient.get('/api/v1/test');
// Should not reach here
expect(true).toBe(false);
} catch (_) {
// Verify refresh was attempted
expect(supabase.auth.refreshSession).toHaveBeenCalled();
// Verify redirect to login
expect(window.location.href).toBe('/login');
}
});
});
+197
View File
@@ -0,0 +1,197 @@
import axios from 'axios';
import { supabase } from '@app/auth/supabase';
import { handleHttpError } from '@app/services/httpErrorHandler';
import { alert } from '@app/components/toast';
import { openPlanSettings } from '@app/utils/appSettings';
// Global credit update callback - will be set by the AuthProvider
let globalCreditUpdateCallback: ((credits: number) => void) | null = null;
// Function to set the global credit update callback
export const setGlobalCreditUpdateCallback = (callback: (credits: number) => void) => {
globalCreditUpdateCallback = callback;
};
// Helper: decode base64url JWT payload safely
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
if (parts.length < 2) return null;
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '=');
const json = typeof atob !== 'undefined'
? atob(padded)
: Buffer.from(padded, 'base64').toString('binary');
return JSON.parse(json);
} catch (e) {
console.warn('[API Client] Failed to decode JWT payload:', e);
return null;
}
}
// Create axios instance with default config
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/', // Use env var or relative path (proxied by Vite in dev)
responseType: 'json',
});
const LOW_CREDIT_THRESHOLD = 10;
function notifyLowCredits(credits: number) {
const title = 'Credit balance low';
const body = `You have ${credits} credits remaining.`;
alert({
alertType: 'warning',
title,
body,
buttonText: 'Top up',
buttonCallback: () => openPlanSettings(),
isPersistentPopup: true,
location: 'bottom-right'
});
}
// Request interceptor to add JWT token to all requests
apiClient.interceptors.request.use(
async (config) => {
try {
// Get the current session from Supabase
const { data: { session }, error } = await supabase.auth.getSession();
if (error) {
console.error('[API Client] Error getting session:', error);
}
// If we have a session with an access token, add it to the Authorization header
if (session?.access_token) {
config.headers.Authorization = `Bearer ${session.access_token}`;
const payload = decodeJwtPayload(session.access_token);
const role = (payload?.['role'] as string) || (payload?.['user_role'] as string) || undefined;
const aud = payload?.['aud'] as string | undefined;
const isAnon = role === 'anon' || aud === 'anon';
// Debug logs for visibility during integration
if (import.meta.env.DEV) {
console.debug('[API Client] Added JWT token to request:', config.url);
console.debug('[API Client] JWT payload:', payload);
console.debug('[API Client] Token role:', role, '| aud:', aud, '| isAnon:', isAnon);
}
} else {
console.debug('[API Client] No JWT token available for request:', config.url);
}
} catch (error) {
console.error('[API Client] Error in request interceptor:', error);
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// List of endpoints that don't require authentication
const publicEndpoints = [
'/api/v1/config/app-config',
'/api/v1/info/status',
'/api/v1/config/public-config',
'/api/v1/config/endpoints-enabled',
];
// Response interceptor for handling token refresh and credit updates
apiClient.interceptors.response.use(
(response) => {
// Check for X-Credits-Remaining header and update credits automatically
const creditsRemaining = response.headers['x-credits-remaining'];
if (creditsRemaining && globalCreditUpdateCallback) {
const credits = parseInt(creditsRemaining, 10);
if (!isNaN(credits) && credits >= 0) {
console.debug('[API Client] Updating credits from response header:', credits, 'for URL:', response.config?.url);
globalCreditUpdateCallback(credits);
// Show low-credit toast with top-up button when below threshold
if (credits < LOW_CREDIT_THRESHOLD) {
notifyLowCredits(credits);
}
} else {
console.warn('[API Client] Invalid credits value in response header:', creditsRemaining);
}
}
if (response.config?.url?.includes('/api/v1/credits')) {
console.debug('[API Client] Credits endpoint response headers:', response.headers);
}
return response;
},
async (error) => {
const originalRequest = error.config;
const isPublicEndpoint = publicEndpoints.some(endpoint =>
originalRequest.url?.includes(endpoint)
);
// If we get a 401 and haven't already tried to refresh, and it's not a public endpoint
if (error.response?.status === 401 && !originalRequest._retry && !isPublicEndpoint) {
originalRequest._retry = true;
try {
// Check if we have a session to refresh
const { data: { session } } = await supabase.auth.getSession();
// Only try to refresh if we actually have a session
if (session) {
const { data: { session: refreshedSession }, error: refreshError } = await supabase.auth.refreshSession();
if (refreshError) {
console.error('[API Client] Token refresh failed:', refreshError);
// Only redirect to login for protected endpoints, not public ones
const isPublicEndpoint = originalRequest.url?.includes('/api/v1/config/') ||
originalRequest.url?.includes('/api/v1/info/');
if (!isPublicEndpoint) {
// Redirect to login only for protected endpoints
window.location.href = '/login';
}
return Promise.reject(error);
}
if (refreshedSession?.access_token) {
// Update the Authorization header with the new token
originalRequest.headers = originalRequest.headers || {};
originalRequest.headers.Authorization = `Bearer ${refreshedSession.access_token}`;
console.debug('[API Client] Retrying request with refreshed token');
// Retry the original request with the new token
return apiClient(originalRequest);
}
} else {
// No session exists, only redirect if not already on login page
console.debug('[API Client] No session to refresh, 401 on protected endpoint');
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
} catch (refreshError) {
console.error('[API Client] Error during token refresh:', refreshError);
}
}
// For public endpoints with 401, just log and continue (don't redirect)
if (isPublicEndpoint && error.response?.status === 401) {
console.debug('[API Client] 401 on public endpoint, continuing without auth:', originalRequest.url);
}
const status = error.response?.status;
const url = error.config?.url;
const method = error.config?.method?.toUpperCase();
console.error('[API Client] HTTP Error', {
status,
method,
url,
error: error.message,
data: error.response?.data
});
await handleHttpError(error); // Handle error (shows toast unless suppressed)
return Promise.reject(error);
}
);
export default apiClient;
@@ -0,0 +1,338 @@
/**
* Avatar sync service for OAuth provider profile pictures
* Downloads, optimizes, and syncs profile pictures from OAuth providers
*/
import { supabase } from '@app/auth/supabase'
import type { User } from '@supabase/supabase-js'
const PROFILE_BUCKET = 'profile-pictures'
const AVATAR_SIZE = 256 // 256x256 pixels
const MAX_AVATAR_SIZE = 500 * 1024 // 500KB max file size after optimization
const SYNC_INTERVAL_DAYS = 7 // Resync every 7 days
// Client-side cache to prevent repeated sync attempts in same browser session
const sessionSyncCache = new Map<string, { timestamp: number; success: boolean }>()
export interface ProfilePictureMetadata {
user_id: string
source: 'oauth' | 'upload'
provider: 'google' | 'github' | 'apple' | 'azure' | null
last_synced_at: string | null
created_at: string
updated_at: string
}
/**
* Extract avatar URL from OAuth provider user metadata
* @param user Supabase User object
* @returns Avatar URL or null if not available
*/
export function getProviderAvatarUrl(user: User): string | null {
const provider = user.app_metadata?.provider
const metadata = user.user_metadata
if (!provider || !metadata) {
return null
}
switch (provider) {
case 'google':
case 'azure':
// Google and Azure use 'picture' field
return metadata.picture || null
case 'github':
// GitHub uses 'avatar_url' field
return metadata.avatar_url || null
case 'apple':
// Apple doesn't provide profile pictures via OAuth
return null
default:
return null
}
}
/**
* Download and optimize an avatar image
* Resizes to 256x256 and converts to PNG format
* @param url Avatar URL from OAuth provider
* @returns Optimized image blob
*/
export async function downloadAndOptimizeAvatar(url: string): Promise<Blob> {
try {
// 1. Fetch image from provider URL
const response = await fetch(url, {
mode: 'cors',
credentials: 'omit',
})
if (!response.ok) {
throw new Error(`Failed to download avatar: ${response.status} ${response.statusText}`)
}
const blob = await response.blob()
// 2. Create image bitmap
const img = await createImageBitmap(blob)
// 3. Create canvas and draw scaled image
const canvas = document.createElement('canvas')
canvas.width = AVATAR_SIZE
canvas.height = AVATAR_SIZE
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('Failed to get canvas context')
}
// Draw image scaled to fit (maintains aspect ratio, centered)
const scale = Math.min(AVATAR_SIZE / img.width, AVATAR_SIZE / img.height)
const x = (AVATAR_SIZE - img.width * scale) / 2
const y = (AVATAR_SIZE - img.height * scale) / 2
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
// 4. Convert to PNG blob with quality optimization
return new Promise((resolve, reject) => {
canvas.toBlob(
(optimizedBlob) => {
if (!optimizedBlob) {
reject(new Error('Failed to create optimized blob'))
return
}
// Check file size
if (optimizedBlob.size > MAX_AVATAR_SIZE) {
console.warn('[Avatar Sync] Optimized avatar exceeds max size:', optimizedBlob.size)
// Try with lower quality
canvas.toBlob(
(lowerQualityBlob) => {
if (lowerQualityBlob) {
resolve(lowerQualityBlob)
} else {
reject(new Error('Failed to create lower quality blob'))
}
},
'image/png',
0.7
)
} else {
resolve(optimizedBlob)
}
},
'image/png',
0.9
)
})
} catch (error) {
console.error('[Avatar Sync] Failed to download and optimize avatar:', error)
throw error
}
}
/**
* Upload avatar blob to Supabase Storage
* @param userId User ID
* @param blob Optimized avatar blob
*/
export async function uploadAvatarToStorage(userId: string, blob: Blob): Promise<void> {
try {
const profilePath = `${userId}/avatar`
console.debug('[Avatar Sync] Uploading avatar to storage:', profilePath)
// Upload to Supabase Storage (overwrites existing file)
const { error: uploadError } = await supabase.storage
.from(PROFILE_BUCKET)
.upload(profilePath, blob, {
upsert: true, // Overwrite existing file
contentType: 'image/png',
cacheControl: '3600', // Cache for 1 hour
})
if (uploadError) {
throw uploadError
}
console.debug('[Avatar Sync] Avatar uploaded successfully')
} catch (error) {
console.error('[Avatar Sync] Failed to upload avatar to storage:', error)
throw error
}
}
/**
* Fetch profile picture metadata for a user
* @param userId User ID
* @returns Metadata or null if not found
*/
export async function getProfilePictureMetadata(
userId: string
): Promise<ProfilePictureMetadata | null> {
try {
const { data, error } = await supabase
.from('profile_picture_metadata')
.select('*')
.eq('user_id', userId)
.maybeSingle()
if (error) {
// If table doesn't exist, that's expected before migration runs
if (error.code === 'PGRST116' || error.message?.includes('does not exist')) {
console.debug('[Avatar Sync] Metadata table not found - migration may not be applied yet')
return null
}
console.error('[Avatar Sync] Failed to fetch profile picture metadata:', error)
return null
}
return data
} catch (error) {
console.error('[Avatar Sync] Unexpected error fetching metadata:', error)
return null
}
}
/**
* Update or insert profile picture metadata
* @param userId User ID
* @param data Partial metadata to update
*/
export async function updateProfilePictureMetadata(
userId: string,
data: Partial<Omit<ProfilePictureMetadata, 'user_id' | 'created_at' | 'updated_at'>>
): Promise<void> {
try {
const { error } = await supabase.from('profile_picture_metadata').upsert(
{
user_id: userId,
...data,
},
{
onConflict: 'user_id',
}
)
if (error) {
// If table doesn't exist, log but don't crash
if (error.code === 'PGRST116' || error.message?.includes('does not exist')) {
console.warn('[Avatar Sync] Cannot update metadata - table does not exist. Run migration first.')
return // Don't throw, allow feature to work without metadata tracking
}
throw error
}
console.debug('[Avatar Sync] Metadata updated successfully')
} catch (error) {
console.error('[Avatar Sync] Failed to update metadata:', error)
throw error
}
}
/**
* Main function to sync OAuth avatar for a user
* Downloads avatar from OAuth provider and uploads to Supabase Storage
* Only syncs if:
* - User is authenticated via OAuth provider that supports avatars
* - User hasn't manually uploaded a picture (source !== 'upload')
* - Last sync was more than SYNC_INTERVAL_DAYS ago (or never synced)
*
* @param user Supabase User object
* @returns true if sync was performed, false if skipped
*/
export async function syncOAuthAvatar(user: User): Promise<boolean> {
const cacheKey = user.id
try {
// 0. Check client-side session cache first (prevent repeated attempts)
const cached = sessionSyncCache.get(cacheKey)
if (cached) {
const minutesSinceLastAttempt = (Date.now() - cached.timestamp) / (1000 * 60)
if (minutesSinceLastAttempt < 60) {
console.debug('[Avatar Sync] Skipping sync - already attempted in this session:', {
minutesAgo: minutesSinceLastAttempt.toFixed(1),
lastSuccess: cached.success
})
return cached.success
}
}
// 1. Check if user is OAuth authenticated
const provider = user.app_metadata?.provider
console.debug('[Avatar Sync] Checking user for sync:', {
provider,
userId: user.id,
email: user.email,
hasUserMetadata: !!user.user_metadata,
userMetadataKeys: user.user_metadata ? Object.keys(user.user_metadata) : []
})
if (!provider || !['google', 'github', 'azure'].includes(provider)) {
console.debug('[Avatar Sync] Skipping sync - not an OAuth provider with avatar support')
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false })
return false
}
// 2. Get metadata to check if sync is needed
const metadata = await getProfilePictureMetadata(user.id)
// Skip if user has manually uploaded a picture
if (metadata?.source === 'upload') {
console.debug('[Avatar Sync] Skipping sync - user has manual upload')
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false })
return false
}
// Skip if synced recently (within SYNC_INTERVAL_DAYS)
if (metadata?.last_synced_at) {
const lastSync = new Date(metadata.last_synced_at)
const daysSinceSync = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60 * 24)
if (daysSinceSync < SYNC_INTERVAL_DAYS) {
console.debug('[Avatar Sync] Skipping sync - synced recently:', {
daysSinceSync: daysSinceSync.toFixed(1),
threshold: SYNC_INTERVAL_DAYS,
})
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: true })
return false
}
}
// 3. Extract provider avatar URL
const avatarUrl = getProviderAvatarUrl(user)
console.debug('[Avatar Sync] Avatar URL extraction:', {
provider,
avatarUrl,
hasAvatarUrl: !!avatarUrl
})
if (!avatarUrl) {
console.debug('[Avatar Sync] No avatar URL available from provider')
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false })
return false
}
console.debug('[Avatar Sync] Starting sync for provider:', provider, 'with URL:', avatarUrl)
// 4. Download and optimize avatar
const optimizedBlob = await downloadAndOptimizeAvatar(avatarUrl)
// 5. Upload to Supabase Storage
await uploadAvatarToStorage(user.id, optimizedBlob)
// 6. Update metadata
await updateProfilePictureMetadata(user.id, {
source: 'oauth',
provider: provider as ProfilePictureMetadata['provider'],
last_synced_at: new Date().toISOString(),
})
console.debug('[Avatar Sync] Sync completed successfully')
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: true })
return true
} catch (error) {
console.error('[Avatar Sync] Failed to sync OAuth avatar:', error)
// Cache the failure to prevent repeated attempts
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false })
// Don't throw - gracefully degrade to existing picture or initials
return false
}
}
@@ -0,0 +1,147 @@
import type { SavedSignature } from '@app/hooks/tools/sign/useSavedSignatures';
export type StorageType = 'backend' | 'localStorage';
interface SignatureStorageCapabilities {
supportsBackend: boolean;
storageType: StorageType;
}
/**
* SaaS-specific signature storage service that always uses localStorage.
*
* In SaaS mode, the proprietary backend signature API is not available
* (requires Spring Security JWT, not Supabase JWT), so we skip detection
* and force localStorage-only mode to avoid unnecessary 401/403 errors.
*/
class SignatureStorageService {
private capabilities: SignatureStorageCapabilities | null = null;
private blobUrls: Set<string> = new Set();
private readonly STORAGE_KEY = 'stirling:saved-signatures:v1';
/**
* Detect capabilities - in SaaS mode, always returns localStorage
*/
async detectCapabilities(): Promise<SignatureStorageCapabilities> {
if (this.capabilities) {
return this.capabilities;
}
// SaaS mode always uses localStorage (no backend signature API available)
console.log('[SignatureStorage] SaaS mode - using localStorage (backend not available)');
this.capabilities = {
supportsBackend: false,
storageType: 'localStorage',
};
return this.capabilities;
}
/**
* Get current storage type
*/
async getStorageType(): Promise<StorageType> {
const capabilities = await this.detectCapabilities();
return capabilities.storageType;
}
/**
* Load all signatures
*/
async loadSignatures(): Promise<SavedSignature[]> {
// Clean up old blob URLs before loading new ones
this.cleanup();
// Always use localStorage in SaaS mode
return this._loadFromLocalStorage();
}
/**
* Save a signature
*/
async saveSignature(signature: SavedSignature): Promise<void> {
// Force scope to localStorage for SaaS mode
signature.scope = 'localStorage';
this._saveToLocalStorage(signature);
}
/**
* Delete a signature
*/
async deleteSignature(id: string): Promise<void> {
this._deleteFromLocalStorage(id);
}
/**
* Update signature label
*/
async updateSignatureLabel(id: string, label: string): Promise<void> {
this._updateLabelInLocalStorage(id, label);
}
// LocalStorage methods
private _loadFromLocalStorage(): SavedSignature[] {
try {
const raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return [];
const signatures = JSON.parse(raw);
// Ensure all localStorage signatures have the correct scope
return signatures.map((sig: SavedSignature) => ({
...sig,
scope: 'localStorage' as const,
}));
} catch {
return [];
}
}
private _saveToLocalStorage(signature: SavedSignature): void {
const signatures = this._loadFromLocalStorage();
const index = signatures.findIndex(s => s.id === signature.id);
if (index >= 0) {
signatures[index] = signature;
} else {
signatures.unshift(signature);
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
}
private _deleteFromLocalStorage(id: string): void {
const signatures = this._loadFromLocalStorage();
const filtered = signatures.filter(s => s.id !== id);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
}
private _updateLabelInLocalStorage(id: string, label: string): void {
const signatures = this._loadFromLocalStorage();
const signature = signatures.find(s => s.id === id);
if (signature) {
signature.label = label;
signature.updatedAt = Date.now();
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
}
}
/**
* Migrate signatures from localStorage to backend
* In SaaS mode, this is a no-op since we don't support backend storage
*/
async migrateToBackend(): Promise<{ migrated: number; failed: number }> {
console.log('[SignatureStorage] Migration not supported in SaaS mode');
return { migrated: 0, failed: 0 };
}
/**
* Clean up blob URLs to prevent memory leaks
*/
cleanup(): void {
this.blobUrls.forEach(url => {
URL.revokeObjectURL(url);
});
this.blobUrls.clear();
}
}
export const signatureStorageService = new SignatureStorageService();
@@ -0,0 +1,272 @@
import apiClient from '@app/services/apiClient';
import { supabase, isSupabaseConfigured } from '@app/services/supabaseClient';
export interface User {
id: number;
username: string;
email?: string;
supabaseId?: string | null;
roleName: string; // Translation key like "adminUserSettings.admin"
rolesAsString?: string; // Actual role ID like "ROLE_ADMIN"
enabled: boolean;
isFirstLogin?: boolean;
authenticationType?: string;
team?: {
id: number;
name: string;
};
createdAt?: string;
updatedAt?: string;
// Enriched client-side fields
isActive?: boolean;
lastRequest?: number; // timestamp in milliseconds
}
export interface AdminSettingsData {
users: User[];
userSessions: Record<string, boolean>;
userLastRequest: Record<string, number>; // username -> timestamp in milliseconds
totalUsers: number;
activeUsers: number;
disabledUsers: number;
currentUsername?: string;
roleDetails?: Record<string, string>;
teams?: any[];
maxPaidUsers?: number;
// License information
maxAllowedUsers: number;
availableSlots: number;
licenseMaxUsers: number;
premiumEnabled: boolean;
}
export interface CreateUserRequest {
username: string;
password?: string;
role: string;
teamId?: number;
authType: 'password' | 'SSO';
forceChange?: boolean;
}
export interface UpdateUserRoleRequest {
username: string;
role: string;
teamId?: number;
}
export interface InviteUsersRequest {
emails: string; // Comma-separated email addresses
role: string;
teamId?: number;
}
export interface InviteUsersResponse {
successCount: number;
failureCount: number;
message?: string;
errors?: string;
error?: string;
}
export interface InviteLinkRequest {
email?: string;
role: string;
teamId?: number;
expiryHours?: number;
sendEmail?: boolean;
}
export interface InviteLinkResponse {
token: string;
inviteUrl: string;
email: string;
expiresAt: string;
expiryHours: number;
emailSent?: boolean;
emailError?: string;
error?: string;
}
export interface InviteToken {
id: number;
email: string;
role: string;
teamId?: number;
createdBy: string;
createdAt: string;
expiresAt: string;
}
/**
* User Management Service
* Provides functions to interact with user management backend APIs
*/
export const userManagementService = {
/**
* Get all users with session data (admin only)
*/
async getUsers(): Promise<AdminSettingsData> {
const response = await apiClient.get<AdminSettingsData>('/api/v1/proprietary/ui-data/admin-settings');
return response.data;
},
/**
* Get users without a team
*/
async getUsersWithoutTeam(): Promise<User[]> {
const response = await apiClient.get<User[]>('/api/v1/users/without-team');
return response.data;
},
/**
* Create a new user (admin only)
*/
async createUser(data: CreateUserRequest): Promise<void> {
const formData = new FormData();
formData.append('username', data.username);
if (data.password) {
formData.append('password', data.password);
}
formData.append('role', data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
}
formData.append('authType', data.authType);
if (data.forceChange !== undefined) {
formData.append('forceChange', data.forceChange.toString());
}
await apiClient.post('/api/v1/user/admin/saveUser', formData, {
suppressErrorToast: true, // Component will handle error display
} as any);
},
/**
* Update user role and/or team (admin only)
*/
async updateUserRole(data: UpdateUserRoleRequest): Promise<void> {
const formData = new FormData();
formData.append('username', data.username);
formData.append('role', data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
}
await apiClient.post('/api/v1/user/admin/changeRole', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Enable or disable a user (admin only)
*/
async toggleUserEnabled(username: string, enabled: boolean): Promise<void> {
const formData = new FormData();
formData.append('enabled', enabled.toString());
await apiClient.post(`/api/v1/user/admin/changeUserEnabled/${username}`, formData, {
suppressErrorToast: true,
} as any);
},
/**
* Delete a user (admin only)
*/
async deleteUser(user: User, options?: { notifyUser?: boolean }): Promise<void> {
if (isSupabaseConfigured && supabase) {
if (!user.email) {
throw new Error('Email missing for this user. Please contact support for manual removal.');
}
const { error } = await supabase.functions.invoke('delete-user', {
body: {
target_email: user.email,
notify_user: options?.notifyUser ?? true,
},
});
if (error) {
throw new Error(error.message || 'Supabase deletion failed');
}
return;
}
},
/**
* Invite users via email (admin only)
* Sends comma-separated email addresses, creates accounts with random passwords,
* and sends invitation emails
*/
async inviteUsers(data: InviteUsersRequest): Promise<InviteUsersResponse> {
const formData = new FormData();
formData.append('emails', data.emails);
formData.append('role', data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
}
const response = await apiClient.post<InviteUsersResponse>(
'/api/v1/user/admin/inviteUsers',
formData,
{
suppressErrorToast: true, // Component will handle error display
} as any
);
return response.data;
},
/**
* Generate an invite link (admin only)
*/
async generateInviteLink(data: InviteLinkRequest): Promise<InviteLinkResponse> {
const formData = new FormData();
// Only append email if it's provided and not empty
if (data.email && data.email.trim()) {
formData.append('email', data.email);
}
formData.append('role', data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
}
if (data.expiryHours) {
formData.append('expiryHours', data.expiryHours.toString());
}
if (data.sendEmail !== undefined) {
formData.append('sendEmail', data.sendEmail.toString());
}
const response = await apiClient.post<InviteLinkResponse>(
'/api/v1/invite/generate',
formData,
{
suppressErrorToast: true,
} as any
);
return response.data;
},
/**
* Get list of active invite links (admin only)
*/
async getInviteLinks(): Promise<InviteToken[]> {
const response = await apiClient.get<{ invites: InviteToken[] }>('/api/v1/invite/list');
return response.data.invites;
},
/**
* Revoke an invite link (admin only)
*/
async revokeInviteLink(inviteId: number): Promise<void> {
await apiClient.delete(`/api/v1/invite/revoke/${inviteId}`, {
suppressErrorToast: true,
} as any);
},
/**
* Clean up expired invite links (admin only)
*/
async cleanupExpiredInvites(): Promise<{ deletedCount: number }> {
const response = await apiClient.post<{ deletedCount: number }>('/api/v1/invite/cleanup');
return response.data;
},
};
+43
View File
@@ -0,0 +1,43 @@
/**
* User service for handling user-related API calls
*/
const API_BASE = '/api/v1';
/**
* Synchronizes user upgrade from anonymous to authenticated status with the backend.
* This should be called after Supabase has successfully upgraded the user.
* Only the current user can upgrade their own account - the backend determines
* the user from the security context and derives email from SupabaseUser.
*
* @param authMethod - The authentication method used (e.g., "email", "google", "github", "apple", "azure")
* @returns Promise with the synchronization result
*/
export const synchronizeUserUpgrade = async (
authMethod?: string
): Promise<{
message: string;
userId: string;
email: string;
}> => {
const formData = new URLSearchParams();
if (authMethod) {
formData.append('authMethod', authMethod);
}
const response = await fetch(`${API_BASE}/user-role/promptToAuthUser`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
credentials: 'include', // Include cookies for authentication
body: formData.toString(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Failed to synchronize user upgrade' }));
throw new Error(errorData.error || 'Failed to synchronize user upgrade');
}
return response.json();
};
+175
View File
@@ -0,0 +1,175 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Mock localStorage for tests
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
removeItem: vi.fn((key: string) => { delete store[key]; }),
clear: vi.fn(() => { store = {}; }),
get length() { return Object.keys(store).length; },
key: vi.fn((index: number) => Object.keys(store)[index] || null),
};
})();
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
// Mock Supabase for tests
vi.mock('@app/auth/supabase', () => ({
supabase: {
auth: {
getSession: vi.fn().mockResolvedValue({ data: { session: null }, error: null }),
refreshSession: vi.fn().mockResolvedValue({ data: { session: null }, error: null }),
onAuthStateChange: vi.fn().mockReturnValue({ data: { subscription: { unsubscribe: vi.fn() } } })
}
},
debugAuthEvents: vi.fn()
}))
// Mock i18next for tests
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: {
changeLanguage: vi.fn(),
},
}),
initReactI18next: {
type: '3rdParty',
init: vi.fn(),
},
I18nextProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock i18next-http-backend
vi.mock('i18next-http-backend', () => ({
default: {
type: 'backend',
init: vi.fn(),
read: vi.fn(),
save: vi.fn(),
},
}));
// Mock window.URL.createObjectURL and revokeObjectURL for tests
global.URL.createObjectURL = vi.fn(() => 'mocked-url');
global.URL.revokeObjectURL = vi.fn();
// Mock File and Blob API methods that aren't available in jsdom
if (!globalThis.File.prototype.arrayBuffer) {
globalThis.File.prototype.arrayBuffer = function() {
// Return a simple ArrayBuffer with some mock data
const buffer = new ArrayBuffer(8);
const view = new Uint8Array(buffer);
view.set([1, 2, 3, 4, 5, 6, 7, 8]);
return Promise.resolve(buffer);
};
}
if (!globalThis.Blob.prototype.arrayBuffer) {
globalThis.Blob.prototype.arrayBuffer = function() {
// Return a simple ArrayBuffer with some mock data
const buffer = new ArrayBuffer(8);
const view = new Uint8Array(buffer);
view.set([1, 2, 3, 4, 5, 6, 7, 8]);
return Promise.resolve(buffer);
};
}
// Mock crypto.subtle for hashing in tests - force override even if exists
const mockHashBuffer = new ArrayBuffer(32);
const mockHashView = new Uint8Array(mockHashBuffer);
// Fill with predictable mock hash data
for (let i = 0; i < 32; i++) {
mockHashView[i] = i;
}
// Force override crypto.subtle to avoid Node.js native implementation
Object.defineProperty(globalThis, 'crypto', {
value: {
subtle: {
digest: vi.fn().mockImplementation(async (_algorithm: string, _data: any) => {
// Always return the mock hash buffer regardless of input
return mockHashBuffer.slice();
}),
},
getRandomValues: vi.fn().mockImplementation((array: any) => {
// Mock getRandomValues if needed
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
return array;
}),
} as unknown as Crypto,
writable: true,
configurable: true,
});
// Mock Worker for tests (Web Workers not available in test environment)
global.Worker = vi.fn().mockImplementation(() => ({
postMessage: vi.fn(),
terminate: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
onmessage: null,
onerror: null,
}));
// Mock ResizeObserver for Mantine components
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock IntersectionObserver for components that might use it
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock matchMedia for responsive components
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock DOMMatrix for PDF.js tests
Object.defineProperty(global, 'DOMMatrix', {
value: class MockDOMMatrix {
a = 1; b = 0; c = 0; d = 1; e = 0; f = 0;
m11 = 1; m12 = 0; m13 = 0; m14 = 0;
m21 = 0; m22 = 1; m23 = 0; m24 = 0;
m31 = 0; m32 = 0; m33 = 1; m34 = 0;
m41 = 0; m42 = 0; m43 = 0; m44 = 1;
is2D = true;
isIdentity = true;
toString() { return 'matrix(1, 0, 0, 1, 0, 0)'; }
scale() { return this; }
translate() { return this; }
rotate() { return this; }
inverse() { return this; }
multiply() { return this; }
static fromFloat32Array() { return new MockDOMMatrix(); }
static fromFloat64Array() { return new MockDOMMatrix(); }
static fromMatrix() { return new MockDOMMatrix(); }
},
writable: true,
configurable: true,
})
// Set global test timeout to prevent hangs
vi.setConfig({ testTimeout: 5000, hookTimeout: 5000 });

Some files were not shown because too many files have changed in this diff Show More