Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8e10cd419 | ||
|
|
81dec53488 | ||
|
|
f3f982a123 | ||
|
|
c33d705c32 | ||
|
|
dac70a1749 | ||
|
|
20600ac1c3 | ||
|
|
12d7165f83 | ||
|
|
3e9c55243e | ||
|
|
c67859a1ff | ||
|
|
ab362dfd90 | ||
|
|
848ff9688b | ||
|
|
87554463c7 |
@@ -31,10 +31,15 @@ jobs:
|
||||
project: ${{ steps.changes.outputs.project }}
|
||||
openapi: ${{ steps.changes.outputs.openapi }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4.3.0
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@v3.0.2
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
@@ -51,19 +56,19 @@ jobs:
|
||||
spring-security: [true, false]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.3.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK ${{ matrix.jdk-version }}
|
||||
uses: actions/setup-java@v4.7.1
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4.4.2
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
@@ -89,7 +94,7 @@ jobs:
|
||||
done
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
path: |
|
||||
@@ -106,26 +111,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@v2.13.0
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.3.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4.7.1
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
- uses: gradle/actions/setup-gradle@v4.4.2
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
- name: Generate OpenAPI documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: openapi-docs
|
||||
path: ./SwaggerDoc.json
|
||||
@@ -134,15 +139,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@v2.12.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend dependencies
|
||||
@@ -154,7 +159,7 @@ jobs:
|
||||
- name: Run frontend tests
|
||||
run: cd frontend && npm run test -- --run
|
||||
- name: Upload frontend build artifacts
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: frontend-build
|
||||
path: frontend/dist/
|
||||
@@ -166,13 +171,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.3.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4.7.1
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
@@ -180,7 +185,7 @@ jobs:
|
||||
run: ./gradlew clean checkLicense
|
||||
- name: FAILED - check the licenses for compatibility
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
@@ -207,15 +212,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
@@ -225,11 +230,11 @@ jobs:
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.37.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: 'pip' # caching pip dependencies
|
||||
@@ -256,21 +261,21 @@ jobs:
|
||||
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
|
||||
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
@@ -307,7 +307,6 @@ public class ApplicationProperties {
|
||||
private boolean enableKeyRotation = false;
|
||||
private boolean enableKeyCleanup = true;
|
||||
private int keyRetentionDays = 7;
|
||||
private boolean secureCookie;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -364,6 +363,7 @@ public class ApplicationProperties {
|
||||
private CustomPaths customPaths = new CustomPaths();
|
||||
private String fileUploadLimit;
|
||||
private TempFileManagement tempFileManagement = new TempFileManagement();
|
||||
private List<String> corsAllowedOrigins = new ArrayList<>();
|
||||
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
||||
|
||||
@@ -1,22 +1,49 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final EndpointInterceptor endpointInterceptor;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(endpointInterceptor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
// Only configure CORS if allowed origins are specified
|
||||
if (applicationProperties.getSystem() != null
|
||||
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
|
||||
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
|
||||
|
||||
String[] allowedOrigins =
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.getCorsAllowedOrigins()
|
||||
.toArray(new String[0]);
|
||||
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(allowedOrigins)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
// If no origins are configured, CORS is not enabled (secure by default)
|
||||
}
|
||||
|
||||
// @Override
|
||||
// public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
// // Handler for external static resources - DISABLED in backend-only mode
|
||||
|
||||
@@ -2,7 +2,7 @@ multipart.enabled=true
|
||||
logging.level.org.springframework=WARN
|
||||
logging.level.org.hibernate=WARN
|
||||
logging.level.org.eclipse.jetty=WARN
|
||||
#logging.level.org.springframework.security.saml2=TRACE
|
||||
#logging.level.org.springframework.security.oauth2=DEBUG
|
||||
#logging.level.org.springframework.security=DEBUG
|
||||
#logging.level.org.opensaml=DEBUG
|
||||
#logging.level.stirling.software.proprietary.security=DEBUG
|
||||
@@ -35,12 +35,12 @@ spring.datasource.username=sa
|
||||
spring.datasource.password=
|
||||
spring.h2.console.enabled=false
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
# Defer datasource initialization to ensure that the database is fully set up
|
||||
# before Hibernate attempts to access it. This is particularly useful when
|
||||
# Defer datasource initialization to ensure that the database is fully set up
|
||||
# before Hibernate attempts to access it. This is particularly useful when
|
||||
# using database initialization scripts or tools.
|
||||
spring.jpa.defer-datasource-initialization=true
|
||||
|
||||
# Disable SQL logging to avoid cluttering the logs in production. Enable this
|
||||
# Disable SQL logging to avoid cluttering the logs in production. Enable this
|
||||
# property during development if you need to debug SQL queries.
|
||||
spring.jpa.show-sql=false
|
||||
server.servlet.session.timeout:30m
|
||||
@@ -60,4 +60,4 @@ spring.main.allow-bean-definition-overriding=true
|
||||
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
|
||||
|
||||
# V2 features
|
||||
v2=false
|
||||
v2=true
|
||||
|
||||
@@ -64,7 +64,6 @@ security:
|
||||
enableKeyRotation: true # Set to 'true' to enable key pair rotation
|
||||
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
|
||||
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
|
||||
secureCookie: false # Set to 'true' to use secure cookies for JWTs
|
||||
validation: # PDF signature validation settings
|
||||
trust:
|
||||
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
||||
@@ -107,8 +106,8 @@ mail:
|
||||
from: '' # sender email address
|
||||
|
||||
legal:
|
||||
termsAndConditions: https://www.stirlingpdf.com/terms # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
|
||||
privacyPolicy: https://www.stirlingpdf.com/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder
|
||||
termsAndConditions: https://www.stirling.com/legal/terms-of-service # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
|
||||
privacyPolicy: https://www.stirling.com/legal/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder
|
||||
accessibilityStatement: '' # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
|
||||
cookiePolicy: '' # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
|
||||
impressum: '' # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
|
||||
@@ -125,6 +124,7 @@ system:
|
||||
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
|
||||
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
|
||||
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
|
||||
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS.
|
||||
serverCertificate:
|
||||
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
|
||||
organizationName: Stirling-PDF # Organization name for generated certificates
|
||||
|
||||
@@ -57,7 +57,6 @@ public class CustomAuthenticationSuccessHandler
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.WEB));
|
||||
jwtService.addToken(response, jwt);
|
||||
log.debug("JWT generated for user: {}", userName);
|
||||
|
||||
getRedirectStrategy().sendRedirect(request, response, "/");
|
||||
|
||||
@@ -72,7 +72,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
} else if (!jwtService.extractToken(request).isBlank()) {
|
||||
jwtService.clearToken(response);
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
} else {
|
||||
// Redirect to login page after logout
|
||||
@@ -115,8 +114,12 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
// Set service provider keys for the SamlClient
|
||||
samlClient.setSPKeys(certificate, privateKey);
|
||||
|
||||
// Redirect to identity provider for logout. todo: add relay state
|
||||
samlClient.redirectToIdentityProvider(response, null, nameIdValue);
|
||||
// Build relay state to return user to login page after IdP logout
|
||||
String relayState =
|
||||
UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH;
|
||||
|
||||
// Redirect to identity provider for logout with relay state
|
||||
samlClient.redirectToIdentityProvider(response, relayState, nameIdValue);
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Error retrieving logout URL from Provider {} for user {}",
|
||||
|
||||
@@ -13,7 +13,7 @@ public class RateLimitResetScheduler {
|
||||
|
||||
private final IPRateLimitingFilter rateLimitingFilter;
|
||||
|
||||
@Scheduled(cron = "0 0 0 * * MON") // At 00:00 every Monday TODO: configurable
|
||||
@Scheduled(cron = "${security.rate-limit.reset-schedule:0 0 0 * * MON}")
|
||||
public void resetRateLimit() {
|
||||
rateLimitingFilter.resetRequestCounts();
|
||||
}
|
||||
|
||||
@@ -132,19 +132,15 @@ public class SecurityConfiguration {
|
||||
if (loginEnabledValue) {
|
||||
boolean v2Enabled = appConfig.v2Enabled();
|
||||
|
||||
if (v2Enabled) {
|
||||
http.addFilterBefore(
|
||||
jwtAuthenticationFilter(),
|
||||
UsernamePasswordAuthenticationFilter.class)
|
||||
.exceptionHandling(
|
||||
exceptionHandling ->
|
||||
exceptionHandling.authenticationEntryPoint(
|
||||
jwtAuthenticationEntryPoint));
|
||||
}
|
||||
http.addFilterBefore(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class)
|
||||
.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
.addFilterBefore(
|
||||
rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(firstLoginFilter, IPRateLimitingFilter.class);
|
||||
|
||||
if (v2Enabled) {
|
||||
http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class);
|
||||
}
|
||||
|
||||
if (!securityProperties.getCsrfDisabled()) {
|
||||
CookieCsrfTokenRepository cookieRepo =
|
||||
@@ -156,6 +152,13 @@ public class SecurityConfiguration {
|
||||
csrf ->
|
||||
csrf.ignoringRequestMatchers(
|
||||
request -> {
|
||||
String uri = request.getRequestURI();
|
||||
|
||||
// Ignore CSRF for auth endpoints
|
||||
if (uri.startsWith("/api/v1/auth/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
// If there's no API key, don't ignore CSRF
|
||||
// (return false)
|
||||
@@ -238,9 +241,12 @@ public class SecurityConfiguration {
|
||||
: uri;
|
||||
return trimmedUri.startsWith("/login")
|
||||
|| trimmedUri.startsWith("/oauth")
|
||||
|| trimmedUri.startsWith("/oauth2")
|
||||
|| trimmedUri.startsWith("/saml2")
|
||||
|| trimmedUri.endsWith(".svg")
|
||||
|| trimmedUri.startsWith("/register")
|
||||
|| trimmedUri.startsWith("/signup")
|
||||
|| trimmedUri.startsWith("/auth/callback")
|
||||
|| trimmedUri.startsWith("/error")
|
||||
|| trimmedUri.startsWith("/images/")
|
||||
|| trimmedUri.startsWith("/public/")
|
||||
@@ -252,6 +258,16 @@ public class SecurityConfiguration {
|
||||
|| trimmedUri.startsWith("/favicon")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/info/status")
|
||||
|| trimmedUri.startsWith("/api/v1/config")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/auth/register")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/user/register")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/auth/login")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/auth/refresh")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/me")
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
|| uri.contains("/v1/api-docs");
|
||||
})
|
||||
@@ -277,33 +293,40 @@ public class SecurityConfiguration {
|
||||
// Handle OAUTH2 Logins
|
||||
if (securityProperties.isOauth2Active()) {
|
||||
http.oauth2Login(
|
||||
oauth2 ->
|
||||
oauth2.loginPage("/oauth2")
|
||||
/*
|
||||
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
|
||||
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
|
||||
is set as true, else login fails with an error message advising the same.
|
||||
*/
|
||||
.successHandler(
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService))
|
||||
.failureHandler(
|
||||
new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
userInfoEndpoint ->
|
||||
userInfoEndpoint
|
||||
.oidcUserService(
|
||||
new CustomOAuth2UserService(
|
||||
securityProperties,
|
||||
userService,
|
||||
loginAttemptService))
|
||||
.userAuthoritiesMapper(
|
||||
oAuth2userAuthoritiesMapper))
|
||||
.permitAll());
|
||||
oauth2 -> {
|
||||
// v1: Use /oauth2 as login page for Thymeleaf templates
|
||||
if (!v2Enabled) {
|
||||
oauth2.loginPage("/oauth2");
|
||||
}
|
||||
|
||||
// v2: Don't set loginPage, let default OAuth2 flow handle it
|
||||
oauth2
|
||||
/*
|
||||
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
|
||||
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
|
||||
is set as true, else login fails with an error message advising the same.
|
||||
*/
|
||||
.successHandler(
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService))
|
||||
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
userInfoEndpoint ->
|
||||
userInfoEndpoint
|
||||
.oidcUserService(
|
||||
new CustomOAuth2UserService(
|
||||
securityProperties
|
||||
.getOauth2(),
|
||||
userService,
|
||||
loginAttemptService))
|
||||
.userAuthoritiesMapper(
|
||||
oAuth2userAuthoritiesMapper))
|
||||
.permitAll();
|
||||
});
|
||||
}
|
||||
// Handle SAML
|
||||
if (securityProperties.isSaml2Active() && runningProOrHigher) {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/** REST API Controller for authentication operations. */
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Tag(name = "Authentication", description = "Endpoints for user authentication and registration")
|
||||
public class AuthController {
|
||||
|
||||
private final UserService userService;
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
/**
|
||||
* Login endpoint - replaces Supabase signInWithPassword
|
||||
*
|
||||
* @param request Login credentials (email/username and password)
|
||||
* @param response HTTP response to set JWT cookie
|
||||
* @return User and session information
|
||||
*/
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(
|
||||
@RequestBody UsernameAndPass request, HttpServletResponse response) {
|
||||
try {
|
||||
// Validate input parameters
|
||||
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
|
||||
log.warn("Login attempt with null or empty username");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Username is required"));
|
||||
}
|
||||
|
||||
if (request.getPassword() == null || request.getPassword().isEmpty()) {
|
||||
log.warn(
|
||||
"Login attempt with null or empty password for user: {}",
|
||||
request.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password is required"));
|
||||
}
|
||||
|
||||
log.debug("Login attempt for user: {}", request.getUsername());
|
||||
|
||||
UserDetails userDetails =
|
||||
userDetailsService.loadUserByUsername(request.getUsername().trim());
|
||||
User user = (User) userDetails;
|
||||
|
||||
if (!userService.isPasswordCorrect(user, request.getPassword())) {
|
||||
log.warn("Invalid password for user: {}", request.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid credentials"));
|
||||
}
|
||||
|
||||
if (!user.isEnabled()) {
|
||||
log.warn("Disabled user attempted login: {}", request.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "User account is disabled"));
|
||||
}
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", AuthenticationType.WEB.toString());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
|
||||
String token = jwtService.generateToken(user.getUsername(), claims);
|
||||
|
||||
log.info("Login successful for user: {}", request.getUsername());
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", token, "expires_in", 3600)));
|
||||
|
||||
} catch (UsernameNotFoundException e) {
|
||||
log.warn("User not found: {}", request.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid username or password"));
|
||||
} catch (AuthenticationException e) {
|
||||
log.error("Authentication failed for user: {}", request.getUsername(), e);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid credentials"));
|
||||
} catch (Exception e) {
|
||||
log.error("Login error for user: {}", request.getUsername(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*
|
||||
* @return Current authenticated user information
|
||||
*/
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<?> getCurrentUser() {
|
||||
try {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
if (auth == null
|
||||
|| !auth.isAuthenticated()
|
||||
|| auth.getPrincipal().equals("anonymousUser")) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
UserDetails userDetails = (UserDetails) auth.getPrincipal();
|
||||
User user = (User) userDetails;
|
||||
|
||||
return ResponseEntity.ok(Map.of("user", buildUserResponse(user)));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Get current user error", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout endpoint
|
||||
*
|
||||
* @param response HTTP response
|
||||
* @return Success message
|
||||
*/
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<?> logout(HttpServletResponse response) {
|
||||
try {
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
log.debug("User logged out successfully");
|
||||
|
||||
return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Logout error", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token
|
||||
*
|
||||
* @param request HTTP request containing current JWT cookie
|
||||
* @param response HTTP response to set new JWT cookie
|
||||
* @return New token information
|
||||
*/
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<?> refresh(HttpServletRequest request, HttpServletResponse response) {
|
||||
try {
|
||||
String token = jwtService.extractToken(request);
|
||||
|
||||
if (token == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "No token found"));
|
||||
}
|
||||
|
||||
jwtService.validateToken(token);
|
||||
String username = jwtService.extractUsername(token);
|
||||
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
User user = (User) userDetails;
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", user.getAuthenticationType());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
|
||||
String newToken = jwtService.generateToken(username, claims);
|
||||
|
||||
log.debug("Token refreshed for user: {}", username);
|
||||
|
||||
return ResponseEntity.ok(Map.of("access_token", newToken, "expires_in", 3600));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Token refresh error", e);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build user response object
|
||||
*
|
||||
* @param user User entity
|
||||
* @return Map containing user information
|
||||
*/
|
||||
private Map<String, Object> buildUserResponse(User user) {
|
||||
Map<String, Object> userMap = new HashMap<>();
|
||||
userMap.put("id", user.getId());
|
||||
userMap.put("email", user.getUsername()); // Use username as email
|
||||
userMap.put("username", user.getUsername());
|
||||
userMap.put("role", user.getRolesAsString());
|
||||
userMap.put("enabled", user.isEnabled());
|
||||
|
||||
// Add metadata for OAuth compatibility
|
||||
Map<String, Object> appMetadata = new HashMap<>();
|
||||
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
|
||||
userMap.put("app_metadata", appMetadata);
|
||||
|
||||
return userMap;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Request/Response DTOs
|
||||
// ===========================
|
||||
|
||||
/** Login request DTO */
|
||||
public record LoginRequest(String email, String password) {}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package stirling.software.proprietary.security.controller.api;
|
||||
import java.io.IOException;
|
||||
import java.security.Principal;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -15,7 +16,6 @@ import org.springframework.security.core.session.SessionInformation;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
@@ -56,24 +56,83 @@ public class UserController {
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/register")
|
||||
public String register(@ModelAttribute UsernameAndPass requestModel, Model model)
|
||||
public ResponseEntity<?> register(@RequestBody UsernameAndPass usernameAndPass)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
if (userService.usernameExistsIgnoreCase(requestModel.getUsername())) {
|
||||
model.addAttribute("error", "Username already exists");
|
||||
return "register";
|
||||
}
|
||||
try {
|
||||
log.debug("Registration attempt for user: {}", usernameAndPass.getUsername());
|
||||
|
||||
if (userService.usernameExistsIgnoreCase(usernameAndPass.getUsername())) {
|
||||
log.warn(
|
||||
"Registration failed: username already exists: {}",
|
||||
usernameAndPass.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "User already exists"));
|
||||
}
|
||||
|
||||
if (!userService.isUsernameValid(usernameAndPass.getUsername())) {
|
||||
log.warn(
|
||||
"Registration failed: invalid username format: {}",
|
||||
usernameAndPass.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid username format"));
|
||||
}
|
||||
|
||||
if (usernameAndPass.getPassword() == null
|
||||
|| usernameAndPass.getPassword().length() < 6) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password must be at least 6 characters"));
|
||||
}
|
||||
|
||||
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
userService.saveUser(
|
||||
requestModel.getUsername(),
|
||||
requestModel.getPassword(),
|
||||
team,
|
||||
Role.USER.getRoleId(),
|
||||
false);
|
||||
User user =
|
||||
userService.saveUser(
|
||||
usernameAndPass.getUsername(),
|
||||
usernameAndPass.getPassword(),
|
||||
team,
|
||||
Role.USER.getRoleId(),
|
||||
false);
|
||||
|
||||
log.info("User registered successfully: {}", usernameAndPass.getUsername());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(
|
||||
Map.of(
|
||||
"user",
|
||||
buildUserResponse(user),
|
||||
"message",
|
||||
"Account created successfully. Please log in."));
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
return "redirect:/login?messageType=invalidUsername";
|
||||
log.error("Registration validation error: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Registration error for user: {}", usernameAndPass.getUsername(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Registration failed: " + e.getMessage()));
|
||||
}
|
||||
return "redirect:/login?registered=true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build user response object
|
||||
*
|
||||
* @param user User entity
|
||||
* @return Map containing user information
|
||||
*/
|
||||
private Map<String, Object> buildUserResponse(User user) {
|
||||
Map<String, Object> userMap = new HashMap<>();
|
||||
userMap.put("id", user.getId());
|
||||
userMap.put("email", user.getUsername()); // Use username as email
|
||||
userMap.put("username", user.getUsername());
|
||||
userMap.put("role", user.getRolesAsString());
|
||||
userMap.put("enabled", user.isEnabled());
|
||||
|
||||
// Add metadata for OAuth compatibility
|
||||
Map<String, Object> appMetadata = new HashMap<>();
|
||||
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
|
||||
userMap.put("app_metadata", appMetadata);
|
||||
|
||||
return userMap;
|
||||
}
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
|
||||
@@ -22,6 +22,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
Optional<User> findByApiKey(String apiKey);
|
||||
|
||||
Optional<User> findBySsoProviderAndSsoProviderId(String ssoProvider, String ssoProviderId);
|
||||
|
||||
List<User> findByAuthenticationTypeIgnoreCase(String authenticationType);
|
||||
|
||||
@Query("SELECT u FROM User u WHERE u.team IS NULL")
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import static stirling.software.common.util.RequestUriUtils.isStaticResource;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.*;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.SAML2;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.WEB;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.sql.SQLException;
|
||||
@@ -75,29 +76,60 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String jwtToken = jwtService.extractToken(request);
|
||||
|
||||
if (jwtToken == null) {
|
||||
// Any unauthenticated requests should redirect to /login
|
||||
// Allow specific auth endpoints to pass through without JWT
|
||||
String requestURI = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
if (!requestURI.startsWith(contextPath + "/login")) {
|
||||
response.sendRedirect("/login");
|
||||
// Public auth endpoints that don't require JWT
|
||||
boolean isPublicAuthEndpoint =
|
||||
requestURI.startsWith(contextPath + "/login")
|
||||
|| requestURI.startsWith(contextPath + "/signup")
|
||||
|| requestURI.startsWith(contextPath + "/auth/")
|
||||
|| requestURI.startsWith(contextPath + "/oauth2")
|
||||
|| requestURI.startsWith(contextPath + "/api/v1/auth/login")
|
||||
|| requestURI.startsWith(contextPath + "/api/v1/auth/register")
|
||||
|| requestURI.startsWith(contextPath + "/api/v1/auth/refresh");
|
||||
|
||||
if (!isPublicAuthEndpoint) {
|
||||
// For API requests, return 401 JSON
|
||||
String acceptHeader = request.getHeader("Accept");
|
||||
if (requestURI.startsWith(contextPath + "/api/")
|
||||
|| (acceptHeader != null
|
||||
&& acceptHeader.contains("application/json"))) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\":\"Authentication required\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// For HTML requests (SPA routes), let React Router handle it (serve
|
||||
// index.html)
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// For public auth endpoints without JWT, continue to the endpoint
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Validating JWT token");
|
||||
jwtService.validateToken(jwtToken);
|
||||
log.debug("JWT token validated successfully");
|
||||
} catch (AuthenticationFailureException e) {
|
||||
jwtService.clearToken(response);
|
||||
log.warn("JWT validation failed: {}", e.getMessage());
|
||||
handleAuthenticationFailure(request, response, e);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> claims = jwtService.extractClaims(jwtToken);
|
||||
String tokenUsername = claims.get("sub").toString();
|
||||
log.debug("JWT token username: {}", tokenUsername);
|
||||
|
||||
try {
|
||||
authenticate(request, claims);
|
||||
log.debug("Authentication successful for user: {}", tokenUsername);
|
||||
} catch (SQLException | UnsupportedProviderException e) {
|
||||
log.error("Error processing user authentication for user: {}", tokenUsername, e);
|
||||
handleAuthenticationFailure(
|
||||
@@ -175,21 +207,26 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private void processUserAuthenticationType(Map<String, Object> claims, String username)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
AuthenticationType authenticationType =
|
||||
AuthenticationType.valueOf(claims.getOrDefault("authType", WEB).toString());
|
||||
AuthenticationType.valueOf(
|
||||
claims.getOrDefault("authType", WEB).toString().toUpperCase());
|
||||
log.debug("Processing {} login for {} user", authenticationType, username);
|
||||
|
||||
switch (authenticationType) {
|
||||
case OAUTH2 -> {
|
||||
ApplicationProperties.Security.OAUTH2 oauth2Properties =
|
||||
securityProperties.getOauth2();
|
||||
// Provider IDs should already be set during initial authentication
|
||||
// Pass null here since this is validating an existing JWT token
|
||||
userService.processSSOPostLogin(
|
||||
username, oauth2Properties.getAutoCreateUser(), OAUTH2);
|
||||
username, null, null, oauth2Properties.getAutoCreateUser(), OAUTH2);
|
||||
}
|
||||
case SAML2 -> {
|
||||
ApplicationProperties.Security.SAML2 saml2Properties =
|
||||
securityProperties.getSaml2();
|
||||
// Provider IDs should already be set during initial authentication
|
||||
// Pass null here since this is validating an existing JWT token
|
||||
userService.processSSOPostLogin(
|
||||
username, saml2Properties.getAutoCreateUser(), SAML2);
|
||||
username, null, null, saml2Properties.getAutoCreateUser(), SAML2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +236,10 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
contextPath + "/pdfjs/",
|
||||
contextPath + "/pdfjs-legacy/",
|
||||
contextPath + "/api/v1/info/status",
|
||||
contextPath + "/api/v1/auth/login",
|
||||
contextPath + "/api/v1/auth/register",
|
||||
contextPath + "/api/v1/auth/refresh",
|
||||
contextPath + "/api/v1/auth/me",
|
||||
contextPath + "/site.webmanifest"
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
@@ -59,6 +62,12 @@ public class User implements UserDetails, Serializable {
|
||||
@Column(name = "authenticationtype")
|
||||
private String authenticationType;
|
||||
|
||||
@Column(name = "sso_provider_id")
|
||||
private String ssoProviderId;
|
||||
|
||||
@Column(name = "sso_provider")
|
||||
private String ssoProvider;
|
||||
|
||||
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
||||
private Set<Authority> authorities = new HashSet<>();
|
||||
|
||||
@@ -74,6 +83,14 @@ public class User implements UserDetails, Serializable {
|
||||
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
|
||||
private Map<String, String> settings = new HashMap<>(); // Key-value pairs of settings.
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public String getRoleName() {
|
||||
return Role.getRoleNameByRoleId(getRolesAsString());
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.util.Map;
|
||||
import org.springframework.security.authentication.LockedException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.savedrequest.SavedRequest;
|
||||
@@ -72,12 +73,6 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
throw new LockedException(
|
||||
"Your account has been locked due to too many failed login attempts.");
|
||||
}
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.OAUTH2));
|
||||
jwtService.addToken(response, jwt);
|
||||
}
|
||||
if (userService.isUserDisabled(username)) {
|
||||
getRedirectStrategy()
|
||||
.sendRedirect(request, response, "/logout?userIsDisabled=true");
|
||||
@@ -98,14 +93,95 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true");
|
||||
return;
|
||||
}
|
||||
if (principal instanceof OAuth2User) {
|
||||
if (principal instanceof OAuth2User oAuth2User) {
|
||||
// Extract SSO provider information from OAuth2User
|
||||
String ssoProviderId = oAuth2User.getAttribute("sub"); // OIDC ID
|
||||
// Extract provider from authentication - need to get it from the token/request
|
||||
// For now, we'll extract it in a more generic way
|
||||
String ssoProvider = extractProviderFromAuthentication(authentication);
|
||||
|
||||
userService.processSSOPostLogin(
|
||||
username, oauth2Properties.getAutoCreateUser(), OAUTH2);
|
||||
username,
|
||||
ssoProviderId,
|
||||
ssoProvider,
|
||||
oauth2Properties.getAutoCreateUser(),
|
||||
OAUTH2);
|
||||
}
|
||||
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.OAUTH2));
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt);
|
||||
|
||||
response.sendRedirect(redirectUrl);
|
||||
} else {
|
||||
// v1: redirect directly to home
|
||||
response.sendRedirect(contextPath + "/");
|
||||
}
|
||||
response.sendRedirect(contextPath + "/");
|
||||
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
||||
response.sendRedirect(contextPath + "/logout?invalidUsername=true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the OAuth2 provider registration ID from the authentication object.
|
||||
*
|
||||
* @param authentication The authentication object
|
||||
* @return The provider registration ID (e.g., "google", "github"), or null if not available
|
||||
*/
|
||||
private String extractProviderFromAuthentication(Authentication authentication) {
|
||||
if (authentication instanceof OAuth2AuthenticationToken oauth2Token) {
|
||||
return oauth2Token.getAuthorizedClientRegistrationId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a context-aware redirect URL based on the request's origin
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @param contextPath The application context path
|
||||
* @param jwt The JWT token to include
|
||||
* @return The appropriate redirect URL
|
||||
*/
|
||||
private String buildContextAwareRedirectUrl(
|
||||
HttpServletRequest request, String contextPath, String jwt) {
|
||||
// Try to get the origin from the Referer header first
|
||||
String referer = request.getHeader("Referer");
|
||||
if (referer != null && !referer.isEmpty()) {
|
||||
try {
|
||||
java.net.URL refererUrl = new java.net.URL(referer);
|
||||
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
|
||||
if (refererUrl.getPort() != -1
|
||||
&& refererUrl.getPort() != 80
|
||||
&& refererUrl.getPort() != 443) {
|
||||
origin += ":" + refererUrl.getPort();
|
||||
}
|
||||
return origin + "/auth/callback#access_token=" + jwt;
|
||||
} catch (java.net.MalformedURLException e) {
|
||||
// Fall back to other methods if referer is malformed
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to building from request host/port
|
||||
String scheme = request.getScheme();
|
||||
String serverName = request.getServerName();
|
||||
int serverPort = request.getServerPort();
|
||||
|
||||
StringBuilder origin = new StringBuilder();
|
||||
origin.append(scheme).append("://").append(serverName);
|
||||
|
||||
// Only add port if it's not the default port for the scheme
|
||||
if ((!"http".equals(scheme) || serverPort != 80)
|
||||
&& (!"https".equals(scheme) || serverPort != 443)) {
|
||||
origin.append(":").append(serverPort);
|
||||
}
|
||||
|
||||
return origin.toString() + "/auth/callback#access_token=" + jwt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -41,7 +40,7 @@ import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ConditionalOnBooleanProperty("security.oauth2.enabled")
|
||||
@ConditionalOnProperty(prefix = "security", name = "oauth2.enabled", havingValue = "true")
|
||||
public class OAuth2Configuration {
|
||||
|
||||
public static final String REDIRECT_URI_PATH = "{baseUrl}/login/oauth2/code/";
|
||||
@@ -53,6 +52,9 @@ public class OAuth2Configuration {
|
||||
ApplicationProperties applicationProperties, @Lazy UserService userService) {
|
||||
this.userService = userService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
log.info(
|
||||
"OAuth2Configuration initialized - OAuth2 enabled: {}",
|
||||
applicationProperties.getSecurity().getOauth2().getEnabled());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -75,7 +77,7 @@ public class OAuth2Configuration {
|
||||
private Optional<ClientRegistration> keycloakClientRegistration() {
|
||||
OAUTH2 oauth2 = applicationProperties.getSecurity().getOauth2();
|
||||
|
||||
if (isOAuth2Enabled(oauth2) || isClientInitialised(oauth2)) {
|
||||
if (isOAuth2Disabled(oauth2) || isClientInitialised(oauth2)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -105,7 +107,7 @@ public class OAuth2Configuration {
|
||||
private Optional<ClientRegistration> googleClientRegistration() {
|
||||
OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2();
|
||||
|
||||
if (isOAuth2Enabled(oAuth2) || isClientInitialised(oAuth2)) {
|
||||
if (isOAuth2Disabled(oAuth2) || isClientInitialised(oAuth2)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -138,12 +140,23 @@ public class OAuth2Configuration {
|
||||
private Optional<ClientRegistration> githubClientRegistration() {
|
||||
OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2();
|
||||
|
||||
if (isOAuth2Enabled(oAuth2)) {
|
||||
if (isOAuth2Disabled(oAuth2)) {
|
||||
log.debug("OAuth2 is disabled, skipping GitHub client registration");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Client client = oAuth2.getClient();
|
||||
if (client == null) {
|
||||
log.debug("OAuth2 client configuration is null, skipping GitHub");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
GitHubProvider githubClient = client.getGithub();
|
||||
if (githubClient == null) {
|
||||
log.debug("GitHub client configuration is null");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Provider github =
|
||||
new GitHubProvider(
|
||||
githubClient.getClientId(),
|
||||
@@ -151,7 +164,15 @@ public class OAuth2Configuration {
|
||||
githubClient.getScopes(),
|
||||
githubClient.getUseAsUsername());
|
||||
|
||||
return validateProvider(github)
|
||||
boolean isValid = validateProvider(github);
|
||||
log.info(
|
||||
"GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})",
|
||||
isValid,
|
||||
githubClient.getClientId(),
|
||||
githubClient.getClientSecret() != null ? "***" : "null",
|
||||
githubClient.getScopes());
|
||||
|
||||
return isValid
|
||||
? Optional.of(
|
||||
ClientRegistration.withRegistrationId(github.getName())
|
||||
.clientId(github.getClientId())
|
||||
@@ -171,7 +192,7 @@ public class OAuth2Configuration {
|
||||
private Optional<ClientRegistration> oidcClientRegistration() {
|
||||
OAUTH2 oauth = applicationProperties.getSecurity().getOauth2();
|
||||
|
||||
if (isOAuth2Enabled(oauth) || isClientInitialised(oauth)) {
|
||||
if (isOAuth2Disabled(oauth) || isClientInitialised(oauth)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -207,7 +228,7 @@ public class OAuth2Configuration {
|
||||
: Optional.empty();
|
||||
}
|
||||
|
||||
private boolean isOAuth2Enabled(OAUTH2 oAuth2) {
|
||||
private boolean isOAuth2Disabled(OAUTH2 oAuth2) {
|
||||
return oAuth2 == null || !oAuth2.getEnabled();
|
||||
}
|
||||
|
||||
|
||||
@@ -116,13 +116,41 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
|
||||
return;
|
||||
}
|
||||
log.debug("Processing SSO post-login for user: {}", username);
|
||||
|
||||
// Extract SSO provider information from SAML2 assertion
|
||||
String ssoProviderId = saml2Principal.nameId();
|
||||
String ssoProvider = "saml2"; // fixme
|
||||
|
||||
log.debug(
|
||||
"Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})",
|
||||
username,
|
||||
ssoProvider,
|
||||
ssoProviderId);
|
||||
|
||||
userService.processSSOPostLogin(
|
||||
username, saml2Properties.getAutoCreateUser(), SAML2);
|
||||
username,
|
||||
ssoProviderId,
|
||||
ssoProvider,
|
||||
saml2Properties.getAutoCreateUser(),
|
||||
SAML2);
|
||||
log.debug("Successfully processed authentication for user: {}", username);
|
||||
|
||||
generateJwt(response, authentication);
|
||||
response.sendRedirect(contextPath + "/");
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication,
|
||||
Map.of("authType", AuthenticationType.SAML2));
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl =
|
||||
buildContextAwareRedirectUrl(request, contextPath, jwt);
|
||||
|
||||
response.sendRedirect(redirectUrl);
|
||||
} else {
|
||||
// v1: redirect directly to home
|
||||
response.sendRedirect(contextPath + "/");
|
||||
}
|
||||
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
||||
log.debug(
|
||||
"Invalid username detected for user: {}, redirecting to logout",
|
||||
@@ -136,12 +164,48 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
}
|
||||
}
|
||||
|
||||
private void generateJwt(HttpServletResponse response, Authentication authentication) {
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.SAML2));
|
||||
jwtService.addToken(response, jwt);
|
||||
/**
|
||||
* Builds a context-aware redirect URL based on the request's origin
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @param contextPath The application context path
|
||||
* @param jwt The JWT token to include
|
||||
* @return The appropriate redirect URL
|
||||
*/
|
||||
private String buildContextAwareRedirectUrl(
|
||||
HttpServletRequest request, String contextPath, String jwt) {
|
||||
// Try to get the origin from the Referer header first
|
||||
String referer = request.getHeader("Referer");
|
||||
if (referer != null && !referer.isEmpty()) {
|
||||
try {
|
||||
java.net.URL refererUrl = new java.net.URL(referer);
|
||||
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
|
||||
if (refererUrl.getPort() != -1
|
||||
&& refererUrl.getPort() != 80
|
||||
&& refererUrl.getPort() != 443) {
|
||||
origin += ":" + refererUrl.getPort();
|
||||
}
|
||||
return origin + "/auth/callback#access_token=" + jwt;
|
||||
} catch (java.net.MalformedURLException e) {
|
||||
log.debug(
|
||||
"Malformed referer URL: {}, falling back to request-based origin", referer);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to building from request host/port
|
||||
String scheme = request.getScheme();
|
||||
String serverName = request.getServerName();
|
||||
int serverPort = request.getServerPort();
|
||||
|
||||
StringBuilder origin = new StringBuilder();
|
||||
origin.append(scheme).append("://").append(serverName);
|
||||
|
||||
// Only add port if it's not the default port for the scheme
|
||||
if ((!"http".equals(scheme) || serverPort != 80)
|
||||
&& (!"https".equals(scheme) || serverPort != 443)) {
|
||||
origin.append(":").append(serverPort);
|
||||
}
|
||||
|
||||
return origin + "/auth/callback#access_token=" + jwt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
|
||||
import stirling.software.common.model.enumeration.UsernameAttribute;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@@ -27,13 +26,13 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
|
||||
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
|
||||
|
||||
public CustomOAuth2UserService(
|
||||
ApplicationProperties.Security securityProperties,
|
||||
ApplicationProperties.Security.OAUTH2 oauth2Properties,
|
||||
UserService userService,
|
||||
LoginAttemptService loginAttemptService) {
|
||||
this.securityProperties = securityProperties;
|
||||
this.oauth2Properties = oauth2Properties;
|
||||
this.userService = userService;
|
||||
this.loginAttemptService = loginAttemptService;
|
||||
}
|
||||
@@ -42,14 +41,22 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
|
||||
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
|
||||
try {
|
||||
OidcUser user = delegate.loadUser(userRequest);
|
||||
OAUTH2 oauth2 = securityProperties.getOauth2();
|
||||
UsernameAttribute usernameAttribute =
|
||||
UsernameAttribute.valueOf(oauth2.getUseAsUsername().toUpperCase());
|
||||
String usernameAttributeKey = usernameAttribute.getName();
|
||||
String usernameAttributeKey =
|
||||
UsernameAttribute.valueOf(oauth2Properties.getUseAsUsername().toUpperCase())
|
||||
.getName();
|
||||
|
||||
// todo: save user by OIDC ID instead of username
|
||||
Optional<User> internalUser =
|
||||
userService.findByUsernameIgnoreCase(user.getAttribute(usernameAttributeKey));
|
||||
// Extract SSO provider information
|
||||
String ssoProviderId = user.getSubject(); // Standard OIDC 'sub' claim
|
||||
String ssoProvider = userRequest.getClientRegistration().getRegistrationId();
|
||||
String username = user.getAttribute(usernameAttributeKey);
|
||||
|
||||
log.debug(
|
||||
"OAuth2 login - Provider: {}, ProviderId: {}, Username: {}",
|
||||
ssoProvider,
|
||||
ssoProviderId,
|
||||
username);
|
||||
|
||||
Optional<User> internalUser = userService.findByUsernameIgnoreCase(username);
|
||||
|
||||
if (internalUser.isPresent()) {
|
||||
String internalUsername = internalUser.get().getUsername();
|
||||
|
||||
@@ -14,14 +14,11 @@ import java.util.function.Function;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import io.github.pixee.security.Newlines;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
@@ -29,9 +26,7 @@ import io.jsonwebtoken.MalformedJwtException;
|
||||
import io.jsonwebtoken.UnsupportedJwtException;
|
||||
import io.jsonwebtoken.security.SignatureException;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -43,13 +38,9 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
@Service
|
||||
public class JwtService implements JwtServiceInterface {
|
||||
|
||||
private static final String JWT_COOKIE_NAME = "stirling_jwt";
|
||||
private static final String ISSUER = "Stirling PDF";
|
||||
private static final String ISSUER = "https://stirling.com";
|
||||
private static final long EXPIRATION = 3600000;
|
||||
|
||||
@Value("${stirling.security.jwt.secureCookie:true}")
|
||||
private boolean secureCookie;
|
||||
|
||||
private final KeyPersistenceServiceInterface keyPersistenceService;
|
||||
private final boolean v2Enabled;
|
||||
|
||||
@@ -59,6 +50,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
KeyPersistenceServiceInterface keyPersistenceService) {
|
||||
this.v2Enabled = v2Enabled;
|
||||
this.keyPersistenceService = keyPersistenceService;
|
||||
log.info("JwtService initialized");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -260,47 +252,18 @@ public class JwtService implements JwtServiceInterface {
|
||||
|
||||
@Override
|
||||
public String extractToken(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
|
||||
if (cookies != null) {
|
||||
for (Cookie cookie : cookies) {
|
||||
if (JWT_COOKIE_NAME.equals(cookie.getName())) {
|
||||
return cookie.getValue();
|
||||
}
|
||||
}
|
||||
// Extract from Authorization header Bearer token
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
log.debug("JWT token extracted from Authorization header");
|
||||
return token;
|
||||
}
|
||||
|
||||
log.debug("No JWT token found in Authorization header");
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addToken(HttpServletResponse response, String token) {
|
||||
ResponseCookie cookie =
|
||||
ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token))
|
||||
.httpOnly(true)
|
||||
.secure(secureCookie)
|
||||
.sameSite("Strict")
|
||||
.maxAge(EXPIRATION / 1000)
|
||||
.path("/")
|
||||
.build();
|
||||
|
||||
response.addHeader("Set-Cookie", cookie.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearToken(HttpServletResponse response) {
|
||||
ResponseCookie cookie =
|
||||
ResponseCookie.from(JWT_COOKIE_NAME, "")
|
||||
.httpOnly(true)
|
||||
.secure(secureCookie)
|
||||
.sameSite("None")
|
||||
.maxAge(0)
|
||||
.path("/")
|
||||
.build();
|
||||
|
||||
response.addHeader("Set-Cookie", cookie.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isJwtEnabled() {
|
||||
return v2Enabled;
|
||||
|
||||
@@ -5,7 +5,6 @@ import java.util.Map;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
public interface JwtServiceInterface {
|
||||
|
||||
@@ -66,21 +65,6 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
String extractToken(HttpServletRequest request);
|
||||
|
||||
/**
|
||||
* Add JWT token to HTTP response (header and cookie)
|
||||
*
|
||||
* @param response HTTP servlet response
|
||||
* @param token JWT token to add
|
||||
*/
|
||||
void addToken(HttpServletResponse response, String token);
|
||||
|
||||
/**
|
||||
* Clear JWT token from HTTP response (remove cookie)
|
||||
*
|
||||
* @param response HTTP servlet response
|
||||
*/
|
||||
void clearToken(HttpServletResponse response);
|
||||
|
||||
/**
|
||||
* Check if JWT authentication is enabled
|
||||
*
|
||||
|
||||
@@ -60,19 +60,46 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
private final ApplicationProperties.Security.OAUTH2 oAuth2;
|
||||
|
||||
// Handle OAUTH2 login and user auto creation.
|
||||
public void processSSOPostLogin(
|
||||
String username, boolean autoCreateUser, AuthenticationType type)
|
||||
String username,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
boolean autoCreateUser,
|
||||
AuthenticationType type)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
if (!isUsernameValid(username)) {
|
||||
return;
|
||||
}
|
||||
Optional<User> existingUser = findByUsernameIgnoreCase(username);
|
||||
|
||||
// Find user by SSO provider ID first
|
||||
Optional<User> existingUser;
|
||||
if (ssoProviderId != null && ssoProvider != null) {
|
||||
existingUser =
|
||||
userRepository.findBySsoProviderAndSsoProviderId(ssoProvider, ssoProviderId);
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
log.debug("User found by SSO provider ID: {}", ssoProviderId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
existingUser = findByUsernameIgnoreCase(username);
|
||||
if (existingUser.isPresent()) {
|
||||
User user = existingUser.get();
|
||||
|
||||
// Migrate existing user to use provider ID if not already set
|
||||
if (user.getSsoProviderId() == null && ssoProviderId != null && ssoProvider != null) {
|
||||
log.info("Migrating user {} to use SSO provider ID: {}", username, ssoProviderId);
|
||||
user.setSsoProviderId(ssoProviderId);
|
||||
user.setSsoProvider(ssoProvider);
|
||||
userRepository.save(user);
|
||||
databaseService.exportDatabase();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoCreateUser) {
|
||||
saveUser(username, type);
|
||||
saveUser(username, ssoProviderId, ssoProvider, type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +181,21 @@ public class UserService implements UserServiceInterface {
|
||||
saveUser(username, authenticationType, (Long) null, Role.USER.getRoleId());
|
||||
}
|
||||
|
||||
public void saveUser(
|
||||
String username,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
AuthenticationType authenticationType)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
saveUser(
|
||||
username,
|
||||
ssoProviderId,
|
||||
ssoProvider,
|
||||
authenticationType,
|
||||
(Long) null,
|
||||
Role.USER.getRoleId());
|
||||
}
|
||||
|
||||
private User saveUser(Optional<User> user, String apiKey) {
|
||||
if (user.isPresent()) {
|
||||
user.get().setApiKey(apiKey);
|
||||
@@ -168,6 +210,30 @@ public class UserService implements UserServiceInterface {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
null, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
authenticationType, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
role, // role
|
||||
false, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
AuthenticationType authenticationType,
|
||||
Long teamId,
|
||||
String role)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
null, // password
|
||||
ssoProviderId, // ssoProviderId
|
||||
ssoProvider, // ssoProvider
|
||||
authenticationType, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
@@ -183,6 +249,8 @@ public class UserService implements UserServiceInterface {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
null, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
authenticationType, // authenticationType
|
||||
null, // teamId
|
||||
team, // team
|
||||
@@ -197,6 +265,8 @@ public class UserService implements UserServiceInterface {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
@@ -212,6 +282,8 @@ public class UserService implements UserServiceInterface {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
null, // teamId
|
||||
team, // team
|
||||
@@ -227,6 +299,8 @@ public class UserService implements UserServiceInterface {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
@@ -247,6 +321,8 @@ public class UserService implements UserServiceInterface {
|
||||
saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
@@ -411,6 +487,8 @@ public class UserService implements UserServiceInterface {
|
||||
*
|
||||
* @param username Username for the new user
|
||||
* @param password Password for the user (may be null for SSO/OAuth users)
|
||||
* @param ssoProviderId Unique identifier from SSO provider (may be null for non-SSO users)
|
||||
* @param ssoProvider Name of the SSO provider (may be null for non-SSO users)
|
||||
* @param authenticationType Type of authentication (WEB, SSO, etc.)
|
||||
* @param teamId ID of the team to assign (may be null to use default)
|
||||
* @param team Team object to assign (takes precedence over teamId if both provided)
|
||||
@@ -425,6 +503,8 @@ public class UserService implements UserServiceInterface {
|
||||
private User saveUserCore(
|
||||
String username,
|
||||
String password,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
AuthenticationType authenticationType,
|
||||
Long teamId,
|
||||
Team team,
|
||||
@@ -445,6 +525,12 @@ public class UserService implements UserServiceInterface {
|
||||
user.setPassword(passwordEncoder.encode(password));
|
||||
}
|
||||
|
||||
// Set SSO provider details if provided
|
||||
if (ssoProviderId != null && ssoProvider != null) {
|
||||
user.setSsoProviderId(ssoProviderId);
|
||||
user.setSsoProvider(ssoProvider);
|
||||
}
|
||||
|
||||
// Set authentication type
|
||||
user.setAuthenticationType(authenticationType);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -38,7 +40,6 @@ class CustomLogoutSuccessHandlerTest {
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(jwtService.extractToken(request)).thenReturn(token);
|
||||
doNothing().when(jwtService).clearToken(response);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||
|
||||
@@ -56,14 +57,12 @@ class CustomLogoutSuccessHandlerTest {
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(jwtService.extractToken(request)).thenReturn(token);
|
||||
doNothing().when(jwtService).clearToken(response);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
|
||||
|
||||
verify(response).sendRedirect(logoutPath);
|
||||
verify(jwtService).clearToken(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -127,7 +127,6 @@ class JwtAuthenticationFilterTest {
|
||||
.setAuthentication(any(UsernamePasswordAuthenticationToken.class));
|
||||
verify(jwtService)
|
||||
.generateToken(any(UsernamePasswordAuthenticationToken.class), eq(claims));
|
||||
verify(jwtService).addToken(response, newToken);
|
||||
verify(filterChain).doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.atLeast;
|
||||
import static org.mockito.Mockito.contains;
|
||||
import static org.mockito.Mockito.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -17,7 +15,6 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
@@ -27,13 +24,10 @@ import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
@@ -59,7 +53,7 @@ class JwtServiceTest {
|
||||
private JwtVerificationKey testVerificationKey;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws NoSuchAlgorithmException {
|
||||
void setUp() throws Exception {
|
||||
// Generate a test keypair
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
|
||||
keyPairGenerator.initialize(2048);
|
||||
@@ -224,7 +218,8 @@ class JwtServiceTest {
|
||||
assertEquals("admin", extractedClaims.get("role"));
|
||||
assertEquals("IT", extractedClaims.get("department"));
|
||||
assertEquals(username, extractedClaims.get("sub"));
|
||||
assertEquals("Stirling PDF", extractedClaims.get("iss"));
|
||||
// Verify the constant issuer is set correctly
|
||||
assertEquals("https://stirling.com", extractedClaims.get("iss"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -239,62 +234,27 @@ class JwtServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTokenWithCookie() {
|
||||
void testExtractTokenWithAuthorizationHeader() {
|
||||
String token = "test-token";
|
||||
Cookie[] cookies = {new Cookie("stirling_jwt", token)};
|
||||
when(request.getCookies()).thenReturn(cookies);
|
||||
when(request.getHeader("Authorization")).thenReturn("Bearer " + token);
|
||||
|
||||
assertEquals(token, jwtService.extractToken(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTokenWithNoCookies() {
|
||||
when(request.getCookies()).thenReturn(null);
|
||||
void testExtractTokenWithNoAuthorizationHeader() {
|
||||
when(request.getHeader("Authorization")).thenReturn(null);
|
||||
|
||||
assertNull(jwtService.extractToken(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTokenWithWrongCookie() {
|
||||
Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")};
|
||||
when(request.getCookies()).thenReturn(cookies);
|
||||
void testExtractTokenWithInvalidAuthorizationHeaderFormat() {
|
||||
when(request.getHeader("Authorization")).thenReturn("InvalidFormat token");
|
||||
|
||||
assertNull(jwtService.extractToken(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTokenWithInvalidAuthorizationHeader() {
|
||||
when(request.getCookies()).thenReturn(null);
|
||||
|
||||
assertNull(jwtService.extractToken(request));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
void testAddToken(boolean secureCookie) throws Exception {
|
||||
String token = "test-token";
|
||||
|
||||
// Create new JwtService instance with the secureCookie parameter
|
||||
JwtService testJwtService = createJwtServiceWithSecureCookie(secureCookie);
|
||||
|
||||
testJwtService.addToken(response, token);
|
||||
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=" + token));
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("HttpOnly"));
|
||||
|
||||
if (secureCookie) {
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("Secure"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testClearToken() {
|
||||
jwtService.clearToken(response);
|
||||
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt="));
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("Max-Age=0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateTokenWithKeyId() throws Exception {
|
||||
String username = "testuser";
|
||||
@@ -373,17 +333,4 @@ class JwtServiceTest {
|
||||
// Verify fallback logic was used
|
||||
verify(keystoreService, atLeast(1)).getActiveKey();
|
||||
}
|
||||
|
||||
private JwtService createJwtServiceWithSecureCookie(boolean secureCookie) throws Exception {
|
||||
// Use reflection to create JwtService with custom secureCookie value
|
||||
JwtService testService = new JwtService(true, keystoreService);
|
||||
|
||||
// Set the secureCookie field using reflection
|
||||
java.lang.reflect.Field secureCookieField =
|
||||
JwtService.class.getDeclaredField("secureCookie");
|
||||
secureCookieField.setAccessible(true);
|
||||
secureCookieField.set(testService, secureCookie);
|
||||
|
||||
return testService;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Unified Dockerfile - Frontend + Backend in single container
|
||||
# Supports MODE parameter: BOTH (default), FRONTEND, BACKEND
|
||||
|
||||
# Stage 1: Build Frontend
|
||||
FROM node:20-alpine AS frontend-build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
FROM gradle:8.14-jdk21 AS backend-build
|
||||
|
||||
COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 3: Final unified image
|
||||
FROM alpine:3.22.1
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Labels
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF Unified"
|
||||
LABEL org.opencontainers.image.description="Unified container for Stirling-PDF - Frontend + Backend with MODE parameter"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.vendor="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
|
||||
LABEL maintainer="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.authors="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.version="${VERSION_TAG}"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, unified, API, Spring Boot, React"
|
||||
|
||||
# Copy backend files
|
||||
COPY scripts /scripts
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||
COPY --from=backend-build /app/app/core/build/libs/*.jar app.jar
|
||||
|
||||
# Copy frontend files
|
||||
COPY --from=frontend-build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY docker/unified/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/unified/entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Environment Variables
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=false \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
|
||||
UNO_PATH=/usr/lib/libreoffice/program \
|
||||
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
|
||||
PATH=$PATH:/opt/venv/bin \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf \
|
||||
MODE=BOTH \
|
||||
BACKEND_INTERNAL_PORT=8081 \
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
|
||||
# Install all dependencies
|
||||
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
|
||||
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
|
||||
apk upgrade --no-cache -a && \
|
||||
apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
tini \
|
||||
bash \
|
||||
curl \
|
||||
shadow \
|
||||
su-exec \
|
||||
openssl \
|
||||
openssl-dev \
|
||||
openjdk21-jre \
|
||||
nginx \
|
||||
# Doc conversion
|
||||
gcompat \
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
# OCR MY PDF
|
||||
unpaper \
|
||||
tesseract-ocr-data-eng \
|
||||
tesseract-ocr-data-chi_sim \
|
||||
tesseract-ocr-data-deu \
|
||||
tesseract-ocr-data-fra \
|
||||
tesseract-ocr-data-por \
|
||||
ocrmypdf \
|
||||
# CV
|
||||
py3-opencv \
|
||||
python3 \
|
||||
py3-pip \
|
||||
py3-pillow@testing \
|
||||
py3-pdf2image@testing && \
|
||||
python3 -m venv /opt/venv && \
|
||||
/opt/venv/bin/pip install --upgrade pip setuptools && \
|
||||
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
|
||||
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
|
||||
mv /usr/share/tessdata /usr/share/tessdata-original && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
|
||||
mkdir -p /var/lib/nginx/tmp /var/log/nginx && \
|
||||
fc-cache -f -v && \
|
||||
chmod +x /scripts/* && \
|
||||
chmod +x /entrypoint.sh && \
|
||||
# User permissions
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf /var/lib/nginx /var/log/nginx /usr/share/nginx && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
|
||||
ENTRYPOINT ["tini", "--", "/entrypoint.sh"]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Example Docker Compose for Unified Stirling-PDF Container
|
||||
# MODE=BACKEND: Backend API only (no frontend)
|
||||
|
||||
services:
|
||||
stirling-pdf-backend-only:
|
||||
container_name: Stirling-PDF-Backend-Only
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/Dockerfile.unified
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./stirling/data:/usr/share/tessdata:rw
|
||||
- ./stirling/config:/configs:rw
|
||||
- ./stirling/logs:/logs:rw
|
||||
- ./stirling/customFiles:/customFiles:rw
|
||||
- ./stirling/pipeline:/pipeline:rw
|
||||
environment:
|
||||
# MODE parameter: BACKEND only
|
||||
MODE: BACKEND
|
||||
|
||||
# Standard Stirling-PDF configuration
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
DOCKER_ENABLE_SECURITY: "false"
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
UMASK: "022"
|
||||
|
||||
# Application settings
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: Stirling-PDF
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
|
||||
# Optional: Add OCR languages (comma-separated)
|
||||
# TESSERACT_LANGS: "deu,fra,spa"
|
||||
|
||||
# Optional: Java memory settings
|
||||
# JAVA_CUSTOM_OPTS: "-Xmx4g"
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
reservations:
|
||||
memory: 2G
|
||||
|
||||
# Access the API at: http://localhost:8080/api
|
||||
# Swagger UI at: http://localhost:8080/swagger-ui/index.html
|
||||
@@ -0,0 +1,59 @@
|
||||
# Example Docker Compose for Unified Stirling-PDF Container
|
||||
# MODE=BOTH (default): Frontend + Backend in single container on port 8080
|
||||
|
||||
services:
|
||||
stirling-pdf-unified:
|
||||
container_name: Stirling-PDF-Unified-Both
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/Dockerfile.unified
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./stirling/data:/usr/share/tessdata:rw
|
||||
- ./stirling/config:/configs:rw
|
||||
- ./stirling/logs:/logs:rw
|
||||
- ./stirling/customFiles:/customFiles:rw
|
||||
- ./stirling/pipeline:/pipeline:rw
|
||||
environment:
|
||||
# MODE parameter: BOTH (default), FRONTEND, or BACKEND
|
||||
MODE: BOTH
|
||||
|
||||
# Backend runs internally on this port when MODE=BOTH
|
||||
BACKEND_INTERNAL_PORT: 8081
|
||||
|
||||
# Standard Stirling-PDF configuration
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
DOCKER_ENABLE_SECURITY: "false"
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
UMASK: "022"
|
||||
|
||||
# Application settings
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: Stirling-PDF
|
||||
UI_HOMEDESCRIPTION: Your locally hosted one-stop-shop for all your PDF needs
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
|
||||
# Optional: Add OCR languages (comma-separated)
|
||||
# TESSERACT_LANGS: "deu,fra,spa"
|
||||
|
||||
# Optional: Java memory settings
|
||||
# JAVA_CUSTOM_OPTS: "-Xmx4g"
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
reservations:
|
||||
memory: 2G
|
||||
@@ -0,0 +1,63 @@
|
||||
# Example Docker Compose for Unified Stirling-PDF Container
|
||||
# MODE=FRONTEND: Frontend only, connects to separate backend
|
||||
|
||||
services:
|
||||
stirling-pdf-backend:
|
||||
container_name: Stirling-PDF-Backend
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/Dockerfile.unified
|
||||
ports:
|
||||
- "8081:8080"
|
||||
volumes:
|
||||
- ./stirling/data:/usr/share/tessdata:rw
|
||||
- ./stirling/config:/configs:rw
|
||||
- ./stirling/logs:/logs:rw
|
||||
- ./stirling/customFiles:/customFiles:rw
|
||||
- ./stirling/pipeline:/pipeline:rw
|
||||
environment:
|
||||
MODE: BACKEND
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
DOCKER_ENABLE_SECURITY: "false"
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
UMASK: "022"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
|
||||
stirling-pdf-frontend:
|
||||
container_name: Stirling-PDF-Frontend
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/Dockerfile.unified
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
MODE: FRONTEND
|
||||
|
||||
# Point to the backend service
|
||||
VITE_API_BASE_URL: http://stirling-pdf-backend:8080
|
||||
|
||||
# Minimal config needed for frontend
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
depends_on:
|
||||
- stirling-pdf-backend
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
@@ -0,0 +1,458 @@
|
||||
# Stirling-PDF Unified Container
|
||||
|
||||
Single Docker container that can run as **frontend + backend**, **frontend only**, or **backend only** using the `MODE` environment variable.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### MODE=BOTH (Default)
|
||||
Single container with both frontend and backend on port 8080:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8080 \
|
||||
-e MODE=BOTH \
|
||||
stirlingtools/stirling-pdf:unified
|
||||
```
|
||||
|
||||
Access at: `http://localhost:8080`
|
||||
|
||||
### MODE=FRONTEND
|
||||
Frontend only, connecting to separate backend:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8080 \
|
||||
-e MODE=FRONTEND \
|
||||
-e VITE_API_BASE_URL=http://backend:8080 \
|
||||
stirlingtools/stirling-pdf:unified
|
||||
```
|
||||
|
||||
### MODE=BACKEND
|
||||
Backend API only:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8080 \
|
||||
-e MODE=BACKEND \
|
||||
stirlingtools/stirling-pdf:unified
|
||||
```
|
||||
|
||||
Access API at: `http://localhost:8080/api`
|
||||
Swagger UI at: `http://localhost:8080/swagger-ui/index.html`
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### MODE=BOTH (Default)
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Port 8080 (External) │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ Nginx │ │
|
||||
│ │ • Serves frontend (/) │ │
|
||||
│ │ • Proxies /api/* → backend │ │
|
||||
│ └───────────┬───────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────▼───────────────────┐ │
|
||||
│ │ Backend (Internal 8081) │ │
|
||||
│ │ • Spring Boot │ │
|
||||
│ │ • PDF Processing │ │
|
||||
│ │ • UnoServer │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### MODE=FRONTEND
|
||||
```
|
||||
┌─────────────────────────────┐ ┌──────────────────┐
|
||||
│ Frontend Container │ │ Backend │
|
||||
│ Port 8080 │ │ (External) │
|
||||
│ ┌───────────────────────┐ │ │ │
|
||||
│ │ Nginx │ │──────▶ :8080/api │
|
||||
│ │ • Serves frontend │ │ │ │
|
||||
│ │ • Proxies to backend │ │ │ │
|
||||
│ └───────────────────────┘ │ └──────────────────┘
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### MODE=BACKEND
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Backend Container │
|
||||
│ Port 8080 │
|
||||
│ ┌───────────────────────┐ │
|
||||
│ │ Spring Boot │ │
|
||||
│ │ • API Endpoints │ │
|
||||
│ │ • PDF Processing │ │
|
||||
│ │ • UnoServer │ │
|
||||
│ └───────────────────────┘ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### MODE Configuration
|
||||
|
||||
| Variable | Values | Default | Description |
|
||||
|----------|--------|---------|-------------|
|
||||
| `MODE` | `BOTH`, `FRONTEND`, `BACKEND` | `BOTH` | Container operation mode |
|
||||
|
||||
### MODE=BOTH Specific
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `BACKEND_INTERNAL_PORT` | `8081` | Internal port for backend when MODE=BOTH |
|
||||
|
||||
### MODE=FRONTEND Specific
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `VITE_API_BASE_URL` | `http://backend:8080` | Backend URL for API proxying |
|
||||
|
||||
### Standard Configuration
|
||||
|
||||
All modes support standard Stirling-PDF environment variables:
|
||||
|
||||
- `DISABLE_ADDITIONAL_FEATURES` - Enable/disable OCR and LibreOffice features
|
||||
- `DOCKER_ENABLE_SECURITY` - Enable authentication
|
||||
- `PUID` / `PGID` - User/Group IDs
|
||||
- `SYSTEM_MAXFILESIZE` - Max upload size (MB)
|
||||
- `TESSERACT_LANGS` - Comma-separated OCR language codes
|
||||
- `JAVA_CUSTOM_OPTS` - Additional JVM options
|
||||
|
||||
See full configuration docs at: https://docs.stirlingpdf.com
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose Examples
|
||||
|
||||
### Example 1: All-in-One (MODE=BOTH)
|
||||
|
||||
**File:** `docker/compose/docker-compose-unified-both.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
stirling-pdf:
|
||||
image: stirlingtools/stirling-pdf:unified
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/usr/share/tessdata:rw
|
||||
- ./config:/configs:rw
|
||||
environment:
|
||||
MODE: BOTH
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### Example 2: Separate Frontend & Backend
|
||||
|
||||
**File:** `docker/compose/docker-compose-unified-frontend.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
image: stirlingtools/stirling-pdf:unified
|
||||
ports:
|
||||
- "8081:8080"
|
||||
environment:
|
||||
MODE: BACKEND
|
||||
volumes:
|
||||
- ./data:/usr/share/tessdata:rw
|
||||
- ./config:/configs:rw
|
||||
|
||||
frontend:
|
||||
image: stirlingtools/stirling-pdf:unified
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
MODE: FRONTEND
|
||||
VITE_API_BASE_URL: http://backend:8080
|
||||
depends_on:
|
||||
- backend
|
||||
```
|
||||
|
||||
### Example 3: Backend API Only
|
||||
|
||||
**File:** `docker/compose/docker-compose-unified-backend.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
stirling-pdf-api:
|
||||
image: stirlingtools/stirling-pdf:unified
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
MODE: BACKEND
|
||||
volumes:
|
||||
- ./data:/usr/share/tessdata:rw
|
||||
- ./config:/configs:rw
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Building the Image
|
||||
|
||||
```bash
|
||||
# From repository root
|
||||
docker build -t stirlingtools/stirling-pdf:unified -f docker/Dockerfile.unified .
|
||||
```
|
||||
|
||||
### Build Arguments
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `VERSION_TAG` | Version tag for the image |
|
||||
|
||||
Example:
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg VERSION_TAG=v1.0.0 \
|
||||
-t stirlingtools/stirling-pdf:unified \
|
||||
-f docker/Dockerfile.unified .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Simple Deployment (MODE=BOTH)
|
||||
- **Best for:** Personal use, small teams, simple deployments
|
||||
- **Pros:** Single container, easy setup, minimal configuration
|
||||
- **Cons:** Frontend and backend scale together
|
||||
|
||||
### 2. Scaled Frontend (MODE=FRONTEND + BACKEND)
|
||||
- **Best for:** High traffic, need to scale frontend independently
|
||||
- **Pros:** Scale frontend containers separately, CDN-friendly
|
||||
- **Example:**
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
image: stirlingtools/stirling-pdf:unified
|
||||
environment:
|
||||
MODE: BACKEND
|
||||
deploy:
|
||||
replicas: 1
|
||||
|
||||
frontend:
|
||||
image: stirlingtools/stirling-pdf:unified
|
||||
environment:
|
||||
MODE: FRONTEND
|
||||
VITE_API_BASE_URL: http://backend:8080
|
||||
deploy:
|
||||
replicas: 5 # Scale frontend independently
|
||||
```
|
||||
|
||||
### 3. API-Only (MODE=BACKEND)
|
||||
- **Best for:** Headless deployments, custom frontends, API integrations
|
||||
- **Pros:** Minimal resources, no nginx overhead
|
||||
- **Example:** Use with external frontend or API consumers
|
||||
|
||||
### 4. Multi-Backend Setup
|
||||
- **Best for:** Load balancing, high availability
|
||||
- **Example:**
|
||||
```yaml
|
||||
services:
|
||||
backend-1:
|
||||
image: stirlingtools/stirling-pdf:unified
|
||||
environment:
|
||||
MODE: BACKEND
|
||||
|
||||
backend-2:
|
||||
image: stirlingtools/stirling-pdf:unified
|
||||
environment:
|
||||
MODE: BACKEND
|
||||
|
||||
frontend:
|
||||
image: stirlingtools/stirling-pdf:unified
|
||||
environment:
|
||||
MODE: FRONTEND
|
||||
VITE_API_BASE_URL: http://load-balancer:8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Port Configuration
|
||||
|
||||
All modes use **port 8080** by default:
|
||||
|
||||
- **MODE=BOTH**: Nginx listens on 8080, proxies to backend on internal 8081
|
||||
- **MODE=FRONTEND**: Nginx listens on 8080
|
||||
- **MODE=BACKEND**: Spring Boot listens on 8080
|
||||
|
||||
**Expose port 8080** in all configurations:
|
||||
```yaml
|
||||
ports:
|
||||
- "8080:8080"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Health Checks
|
||||
|
||||
### MODE=BOTH and MODE=BACKEND
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
### MODE=FRONTEND
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check logs
|
||||
```bash
|
||||
docker logs stirling-pdf-container
|
||||
```
|
||||
|
||||
Look for the startup banner:
|
||||
```
|
||||
===================================
|
||||
Stirling-PDF Unified Container
|
||||
MODE: BOTH
|
||||
===================================
|
||||
```
|
||||
|
||||
### Invalid MODE error
|
||||
```
|
||||
ERROR: Invalid MODE 'XYZ'. Must be BOTH, FRONTEND, or BACKEND
|
||||
```
|
||||
**Fix:** Set `MODE` to one of the three valid values.
|
||||
|
||||
### Frontend can't connect to backend (MODE=FRONTEND)
|
||||
**Check:**
|
||||
1. `VITE_API_BASE_URL` points to correct backend URL
|
||||
2. Backend container is running and accessible
|
||||
3. Network connectivity between containers
|
||||
|
||||
### Backend not starting (MODE=BOTH or BACKEND)
|
||||
**Check:**
|
||||
1. Sufficient memory allocated (4GB recommended)
|
||||
2. Java heap size (`JAVA_CUSTOM_OPTS`)
|
||||
3. Volume permissions for `/tmp/stirling-pdf`
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Separate Containers → MODE=BOTH
|
||||
|
||||
**Before:**
|
||||
```yaml
|
||||
services:
|
||||
frontend:
|
||||
image: stirlingtools/stirling-pdf:frontend
|
||||
ports: ["80:80"]
|
||||
|
||||
backend:
|
||||
image: stirlingtools/stirling-pdf:backend
|
||||
ports: ["8080:8080"]
|
||||
```
|
||||
|
||||
**After:**
|
||||
```yaml
|
||||
services:
|
||||
stirling-pdf:
|
||||
image: stirlingtools/stirling-pdf:unified
|
||||
ports: ["8080:8080"]
|
||||
environment:
|
||||
MODE: BOTH
|
||||
```
|
||||
|
||||
### From Legacy → MODE=BACKEND
|
||||
```yaml
|
||||
services:
|
||||
stirling-pdf:
|
||||
image: stirlingtools/stirling-pdf:latest
|
||||
ports: ["8080:8080"]
|
||||
```
|
||||
|
||||
**Becomes:**
|
||||
```yaml
|
||||
services:
|
||||
stirling-pdf:
|
||||
image: stirlingtools/stirling-pdf:unified
|
||||
ports: ["8080:8080"]
|
||||
environment:
|
||||
MODE: BACKEND
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### MODE=BOTH
|
||||
```yaml
|
||||
environment:
|
||||
JAVA_CUSTOM_OPTS: "-Xmx4g -XX:MaxRAMPercentage=75"
|
||||
BACKEND_INTERNAL_PORT: 8081
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
reservations:
|
||||
memory: 2G
|
||||
```
|
||||
|
||||
### MODE=FRONTEND (Lightweight)
|
||||
```yaml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 256M
|
||||
```
|
||||
|
||||
### MODE=BACKEND (Heavy Processing)
|
||||
```yaml
|
||||
environment:
|
||||
JAVA_CUSTOM_OPTS: "-Xmx8g"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 10G
|
||||
reservations:
|
||||
memory: 4G
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **MODE=BOTH**: Backend not exposed externally (runs on internal port)
|
||||
2. **MODE=BACKEND**: API exposed directly - consider API authentication
|
||||
3. **MODE=FRONTEND**: Only serves static files - minimal attack surface
|
||||
|
||||
Enable security features:
|
||||
```yaml
|
||||
environment:
|
||||
DOCKER_ENABLE_SECURITY: "true"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
- Documentation: https://docs.stirlingpdf.com
|
||||
- GitHub Issues: https://github.com/Stirling-Tools/Stirling-PDF/issues
|
||||
- Docker Hub: https://hub.docker.com/r/stirlingtools/stirling-pdf
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See repository for full details
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build script for Stirling-PDF Unified Container
|
||||
# Usage: ./build.sh [version-tag]
|
||||
|
||||
set -e
|
||||
|
||||
VERSION_TAG=${1:-latest}
|
||||
IMAGE_NAME="stirlingtools/stirling-pdf:unified-${VERSION_TAG}"
|
||||
|
||||
echo "==================================="
|
||||
echo "Building Stirling-PDF Unified Container"
|
||||
echo "Version: $VERSION_TAG"
|
||||
echo "Image: $IMAGE_NAME"
|
||||
echo "==================================="
|
||||
|
||||
# Navigate to repository root (assuming script is in docker/unified/)
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
REPO_ROOT="$SCRIPT_DIR/../.."
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Build the image
|
||||
docker build \
|
||||
--build-arg VERSION_TAG="$VERSION_TAG" \
|
||||
-t "$IMAGE_NAME" \
|
||||
-f docker/Dockerfile.unified \
|
||||
.
|
||||
|
||||
echo "==================================="
|
||||
echo "✓ Build complete!"
|
||||
echo "Image: $IMAGE_NAME"
|
||||
echo ""
|
||||
echo "Test the image:"
|
||||
echo " MODE=BOTH: docker run -p 8080:8080 -e MODE=BOTH $IMAGE_NAME"
|
||||
echo " MODE=FRONTEND: docker run -p 8080:8080 -e MODE=FRONTEND $IMAGE_NAME"
|
||||
echo " MODE=BACKEND: docker run -p 8080:8080 -e MODE=BACKEND $IMAGE_NAME"
|
||||
echo "==================================="
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Default MODE to BOTH if not set
|
||||
MODE=${MODE:-BOTH}
|
||||
|
||||
echo "==================================="
|
||||
echo "Stirling-PDF Unified Container"
|
||||
echo "MODE: $MODE"
|
||||
echo "==================================="
|
||||
|
||||
# Function to setup OCR (from init.sh)
|
||||
setup_ocr() {
|
||||
echo "Setting up OCR languages..."
|
||||
|
||||
# Copy tessdata
|
||||
mkdir -p /usr/share/tessdata
|
||||
cp -rn /usr/share/tessdata-original/* /usr/share/tessdata 2>/dev/null || true
|
||||
|
||||
if [ -d /usr/share/tesseract-ocr/4.00/tessdata ]; then
|
||||
cp -r /usr/share/tesseract-ocr/4.00/tessdata/* /usr/share/tessdata 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -d /usr/share/tesseract-ocr/5/tessdata ]; then
|
||||
cp -r /usr/share/tesseract-ocr/5/tessdata/* /usr/share/tessdata 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Install additional languages if specified
|
||||
if [[ -n "$TESSERACT_LANGS" ]]; then
|
||||
SPACE_SEPARATED_LANGS=$(echo $TESSERACT_LANGS | tr ',' ' ')
|
||||
pattern='^[a-zA-Z]{2,4}(_[a-zA-Z]{2,4})?$'
|
||||
for LANG in $SPACE_SEPARATED_LANGS; do
|
||||
if [[ $LANG =~ $pattern ]]; then
|
||||
apk add --no-cache "tesseract-ocr-data-$LANG" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to setup user permissions (from init-without-ocr.sh)
|
||||
setup_permissions() {
|
||||
echo "Setting up user permissions..."
|
||||
|
||||
export JAVA_TOOL_OPTIONS="${JAVA_BASE_OPTS} ${JAVA_CUSTOM_OPTS}"
|
||||
|
||||
# Update user and group IDs
|
||||
if [ ! -z "$PUID" ] && [ "$PUID" != "$(id -u stirlingpdfuser)" ]; then
|
||||
usermod -o -u "$PUID" stirlingpdfuser || true
|
||||
fi
|
||||
|
||||
if [ ! -z "$PGID" ] && [ "$PGID" != "$(getent group stirlingpdfgroup | cut -d: -f3)" ]; then
|
||||
groupmod -o -g "$PGID" stirlingpdfgroup || true
|
||||
fi
|
||||
|
||||
umask "$UMASK" || true
|
||||
|
||||
# Install fonts if needed
|
||||
if [[ -n "$LANGS" ]]; then
|
||||
/scripts/installFonts.sh $LANGS
|
||||
fi
|
||||
|
||||
# Ensure directories exist with correct permissions
|
||||
mkdir -p /tmp/stirling-pdf || true
|
||||
|
||||
# Set ownership and permissions
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup \
|
||||
$HOME /logs /scripts /usr/share/fonts/opentype/noto \
|
||||
/configs /customFiles /pipeline /tmp/stirling-pdf \
|
||||
/var/lib/nginx /var/log/nginx /usr/share/nginx \
|
||||
/app.jar 2>/dev/null || echo "[WARN] Some chown operations failed, may run as host user"
|
||||
|
||||
chmod -R 755 /logs /scripts /usr/share/fonts/opentype/noto \
|
||||
/configs /customFiles /pipeline /tmp/stirling-pdf 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Function to configure nginx
|
||||
configure_nginx() {
|
||||
local backend_url=$1
|
||||
echo "Configuring nginx with backend URL: $backend_url"
|
||||
sed -i "s|\${BACKEND_URL}|${backend_url}|g" /etc/nginx/nginx.conf
|
||||
}
|
||||
|
||||
# Function to run as user or root depending on permissions
|
||||
run_as_user() {
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
# Running as root, use su-exec
|
||||
su-exec stirlingpdfuser "$@"
|
||||
else
|
||||
# Already running as non-root
|
||||
exec "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Setup OCR and permissions
|
||||
setup_ocr
|
||||
setup_permissions
|
||||
|
||||
# Handle different modes
|
||||
case "$MODE" in
|
||||
BOTH)
|
||||
echo "Starting in BOTH mode: Frontend + Backend on port 8080"
|
||||
|
||||
# Configure nginx to proxy to internal backend
|
||||
configure_nginx "http://localhost:${BACKEND_INTERNAL_PORT:-8081}"
|
||||
|
||||
# Start backend on internal port
|
||||
echo "Starting backend on port ${BACKEND_INTERNAL_PORT:-8081}..."
|
||||
run_as_user sh -c "java -Dfile.encoding=UTF-8 \
|
||||
-Djava.io.tmpdir=/tmp/stirling-pdf \
|
||||
-Dserver.port=${BACKEND_INTERNAL_PORT:-8081} \
|
||||
-jar /app.jar" &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# Start unoserver for document conversion
|
||||
run_as_user /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1 &
|
||||
UNO_PID=$!
|
||||
|
||||
# Wait for backend to start
|
||||
sleep 3
|
||||
|
||||
# Start nginx on port 8080
|
||||
echo "Starting nginx on port 8080..."
|
||||
run_as_user nginx -g "daemon off;" &
|
||||
NGINX_PID=$!
|
||||
|
||||
echo "==================================="
|
||||
echo "✓ Frontend available at: http://localhost:8080"
|
||||
echo "✓ Backend API at: http://localhost:8080/api"
|
||||
echo "✓ Backend running internally on port ${BACKEND_INTERNAL_PORT:-8081}"
|
||||
echo "==================================="
|
||||
;;
|
||||
|
||||
FRONTEND)
|
||||
echo "Starting in FRONTEND mode: Frontend only on port 8080"
|
||||
|
||||
# Configure nginx with external backend URL
|
||||
BACKEND_URL=${VITE_API_BASE_URL:-http://backend:8080}
|
||||
configure_nginx "$BACKEND_URL"
|
||||
|
||||
# Start nginx on port 8080
|
||||
echo "Starting nginx on port 8080..."
|
||||
run_as_user nginx -g "daemon off;" &
|
||||
NGINX_PID=$!
|
||||
|
||||
echo "==================================="
|
||||
echo "✓ Frontend available at: http://localhost:8080"
|
||||
echo "✓ Proxying API calls to: $BACKEND_URL"
|
||||
echo "==================================="
|
||||
;;
|
||||
|
||||
BACKEND)
|
||||
echo "Starting in BACKEND mode: Backend only on port 8080"
|
||||
|
||||
# Start backend on port 8080
|
||||
echo "Starting backend on port 8080..."
|
||||
run_as_user sh -c "java -Dfile.encoding=UTF-8 \
|
||||
-Djava.io.tmpdir=/tmp/stirling-pdf \
|
||||
-Dserver.port=8080 \
|
||||
-jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1" &
|
||||
BACKEND_PID=$!
|
||||
|
||||
echo "==================================="
|
||||
echo "✓ Backend API available at: http://localhost:8080/api"
|
||||
echo "✓ Swagger UI at: http://localhost:8080/swagger-ui/index.html"
|
||||
echo "==================================="
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "ERROR: Invalid MODE '$MODE'. Must be BOTH, FRONTEND, or BACKEND"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Wait for all background processes
|
||||
wait
|
||||
@@ -0,0 +1,118 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Add .mjs MIME type mapping
|
||||
types {
|
||||
text/javascript mjs;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
# Global settings for file uploads
|
||||
client_max_body_size 100m;
|
||||
|
||||
# Handle client-side routing - support subpaths
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API calls to backend
|
||||
location /api/ {
|
||||
proxy_pass ${BACKEND_URL}/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# Additional headers for proper API proxying
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
# Timeout settings for large file uploads
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# Request size limits for file uploads
|
||||
client_max_body_size 100m;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# Proxy Swagger UI to backend (including versioned paths)
|
||||
location ~ ^/swagger-ui(.*)$ {
|
||||
proxy_pass ${BACKEND_URL}/swagger-ui$1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# Proxy API docs to backend (with query parameters and sub-paths)
|
||||
location ~ ^/v3/api-docs(.*)$ {
|
||||
proxy_pass ${BACKEND_URL}/v3/api-docs$1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
|
||||
# Proxy v1 API docs to backend (with query parameters and sub-paths)
|
||||
location ~ ^/v1/api-docs(.*)$ {
|
||||
proxy_pass ${BACKEND_URL}/v1/api-docs$1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
|
||||
# Serve .mjs files with correct MIME type (must come before general static assets)
|
||||
location ~* \.mjs$ {
|
||||
try_files $uri =404;
|
||||
add_header Content-Type "text/javascript; charset=utf-8" always;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
}
|
||||
@@ -436,6 +436,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -482,6 +483,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -505,6 +507,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.14.tgz",
|
||||
"integrity": "sha512-lE/vfhA53CxamaCfGWEibrEPr+JeZT42QCF+cOELUwv4+Zt6b+IE6+4wsznx/8wjjJYwllXJ3GJ/un1UzTqARw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/engines": "1.3.14",
|
||||
"@embedpdf/models": "1.3.14"
|
||||
@@ -585,6 +588,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.14.tgz",
|
||||
"integrity": "sha512-77hnNLp0W0FHw8lT7SeqzCgp8bOClfeOAPZdcInu/jPDhVASUGYbtE/0fkLhiaqPH7kyMirNCLif4sF6n4b5vg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
@@ -601,6 +605,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.14.tgz",
|
||||
"integrity": "sha512-nR0ZxNoTQtGqOHhweFh6QJ+nUJ4S4Ag1wWur6vAUAi8U95HUOfZhOEa0polZo0zR9WmmblGqRWjFM+mVSOoi1w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
@@ -617,6 +622,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.14.tgz",
|
||||
"integrity": "sha512-KoJX1MacEWE2DrO1OeZeG/Ehz76//u+ida/xb4r9BfwqAp5TfYlksq09cOvcF8LMW5FY4pbAL+AHKI1Hjz+HNA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
@@ -651,6 +657,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.14.tgz",
|
||||
"integrity": "sha512-IPj7GCQXJBsY++JaU+z7y+FwX5NaDBj4YYV6hsHNtSGf42Y1AdlwJzDYetivG2bA84xmk7KgD1X2Y3eIFBhjwA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
@@ -683,6 +690,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.14.tgz",
|
||||
"integrity": "sha512-fQbt7OlRMLQJMuZj/Bzh0qpRxMw1ld5Qe/OTw8N54b/plljnFA52joE7cITl3H03huWWyHS3NKOScbw7f34dog==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
@@ -717,6 +725,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.14.tgz",
|
||||
"integrity": "sha512-EXENuaAsse3rT6cjA1nYzyrNvoy62ojJl28wblCng6zcs3HSlGPemIQZAvaYKPUxoY608M+6nKlcMQ5neRnk/A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
@@ -788,6 +797,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.14.tgz",
|
||||
"integrity": "sha512-mfJ7EbbU68eKk6oFvQ4ozGJNpxUxWbjQ5Gm3uuB+Gj5/tWgBocBOX36k/9LgivEEeX7g2S0tOgyErljApmH8Vg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
@@ -941,6 +951,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -984,6 +995,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
|
||||
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -2017,6 +2029,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.1.tgz",
|
||||
"integrity": "sha512-OYfxn9cTv+K6RZ8+Ozn/HDQXkB8Fmn+KJJt5lxyFDP9F09EHnC59Ldadv1LyUZVBGtNqz4sn6b3vBShbxwAmYw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -2067,6 +2080,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.1.tgz",
|
||||
"integrity": "sha512-lQutBS+Q0iz/cNFvdrsYassPWo3RtWcmDGJeOtKfHigLzFOhxUuLOkQgepDbMf3WcVMB/tist6Px1PQOv57JTw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^18.x || ^19.x"
|
||||
}
|
||||
@@ -2134,6 +2148,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.2.tgz",
|
||||
"integrity": "sha512-qXvbnawQhqUVfH1LMgMaiytP+ZpGoYhnGl7yYq2x57GYzcFL/iPzSZ3L30tlbwEjSVKNYcbiKO8tANR1tadjUg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.3",
|
||||
"@mui/core-downloads-tracker": "^7.3.2",
|
||||
@@ -2326,6 +2341,203 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
|
||||
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
|
||||
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
|
||||
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@tybys/wasm-util": "^0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -3379,6 +3591,7 @@
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -3702,6 +3915,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
|
||||
"integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -3712,6 +3926,7 @@
|
||||
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
@@ -3772,6 +3987,7 @@
|
||||
"integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.44.1",
|
||||
"@typescript-eslint/types": "8.44.1",
|
||||
@@ -3971,6 +4187,275 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
|
||||
"integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-android-arm64": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
|
||||
"integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-darwin-arm64": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
|
||||
"integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-darwin-x64": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
|
||||
"integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-freebsd-x64": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
|
||||
"integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
|
||||
"integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
|
||||
"integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
|
||||
"integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-arm64-musl": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
|
||||
"integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
|
||||
"integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
|
||||
"integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
|
||||
"integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
|
||||
"integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz",
|
||||
"integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-x64-musl": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz",
|
||||
"integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
|
||||
"integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@napi-rs/wasm-runtime": "^0.2.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
|
||||
"integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
|
||||
"integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
|
||||
"integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react-swc": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.1.0.tgz",
|
||||
@@ -4216,7 +4701,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.21.tgz",
|
||||
"integrity": "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.21"
|
||||
}
|
||||
@@ -4226,7 +4710,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.21.tgz",
|
||||
"integrity": "sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.21",
|
||||
"@vue/shared": "3.5.21"
|
||||
@@ -4237,7 +4720,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.21.tgz",
|
||||
"integrity": "sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.21",
|
||||
"@vue/runtime-core": "3.5.21",
|
||||
@@ -4250,7 +4732,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.21.tgz",
|
||||
"integrity": "sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.21",
|
||||
"@vue/shared": "3.5.21"
|
||||
@@ -4278,6 +4759,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4952,6 +5434,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
@@ -5955,7 +6438,8 @@
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
|
||||
"integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
@@ -6350,6 +6834,7 @@
|
||||
"integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -6521,6 +7006,7 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -7794,6 +8280,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -8590,6 +9077,7 @@
|
||||
"integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@asamuzakjp/dom-selector": "^6.5.4",
|
||||
"cssstyle": "^5.3.0",
|
||||
@@ -10378,6 +10866,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -10669,6 +11158,7 @@
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
|
||||
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
@@ -11041,6 +11531,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
||||
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -11050,6 +11541,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
|
||||
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@@ -12687,6 +13179,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12969,6 +13462,7 @@
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -13051,6 +13545,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"napi-postinstall": "^0.3.0"
|
||||
},
|
||||
@@ -13270,6 +13765,7 @@
|
||||
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -13401,6 +13897,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -13414,6 +13911,7 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"predev": "npm run generate-icons",
|
||||
"dev": "npm run typecheck && vite",
|
||||
"prebuild": "npm run generate-icons",
|
||||
"lint": "eslint",
|
||||
"lint": "eslint --max-warnings=0",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
|
After Width: | Height: | Size: 717 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 229 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 426 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
|
||||
<path d="M0 0h10.5v10.5H0V0z" fill="#F25022"/>
|
||||
<path d="M12.5 0H23v10.5H12.5V0z" fill="#7FBA00"/>
|
||||
<path d="M0 12.5h10.5V23H0V12.5z" fill="#00A4EF"/>
|
||||
<path d="M12.5 12.5H23V23H12.5V12.5z" fill="#FFB900"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 292 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="37" height="36" viewBox="0 0 37 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.5 3.3125C16.5712 3.3125 14.6613 3.6924 12.8793 4.43052C11.0974 5.16864 9.47823 6.25051 8.11437 7.61437C5.35993 10.3688 3.8125 14.1046 3.8125 18C3.8125 24.4919 8.02781 29.9997 13.8588 31.9531C14.5931 32.0706 14.8281 31.6153 14.8281 31.2187V28.7366C10.7597 29.6178 9.89312 26.7684 9.89312 26.7684C9.2175 25.0647 8.26281 24.6094 8.26281 24.6094C6.92625 23.6987 8.36562 23.7281 8.36562 23.7281C9.83437 23.8309 10.6128 25.2409 10.6128 25.2409C11.8906 27.4734 14.0497 26.8125 14.8869 26.46C15.0191 25.5053 15.4009 24.8591 15.8122 24.4919C12.5516 24.1247 9.12937 22.8616 9.12937 17.2656C9.12937 15.6353 9.6875 14.3281 10.6422 13.2853C10.4953 12.9181 9.98125 11.3906 10.7891 9.40781C10.7891 9.40781 12.0228 9.01125 14.8281 10.9059C15.9884 10.5828 17.2516 10.4212 18.5 10.4212C19.7484 10.4212 21.0116 10.5828 22.1719 10.9059C24.9772 9.01125 26.2109 9.40781 26.2109 9.40781C27.0188 11.3906 26.5047 12.9181 26.3578 13.2853C27.3125 14.3281 27.8706 15.6353 27.8706 17.2656C27.8706 22.8762 24.4338 24.11 21.1584 24.4772C21.6872 24.9325 22.1719 25.8284 22.1719 27.1944V31.2187C22.1719 31.6153 22.4069 32.0853 23.1559 31.9531C28.9869 29.985 33.1875 24.4919 33.1875 18C33.1875 16.0712 32.8076 14.1613 32.0695 12.3793C31.3314 10.5974 30.2495 8.97823 28.8856 7.61437C27.5218 6.25051 25.9026 5.16864 24.1207 4.43052C22.3387 3.6924 20.4288 3.3125 18.5 3.3125Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2781_85129)">
|
||||
<path d="M8.36055 0.789432C5.96258 1.62131 3.89457 3.20024 2.46029 5.29431C1.026 7.38838 0.301037 9.8872 0.391883 12.4237C0.482728 14.9603 1.38459 17.4008 2.96501 19.3869C4.54543 21.373 6.72109 22.8 9.17243 23.4582C11.1598 23.971 13.2419 23.9935 15.2399 23.5238C17.0499 23.1172 18.7233 22.2476 20.0962 21.0001C21.5251 19.662 22.5622 17.9597 23.0962 16.0763C23.6765 14.0282 23.7798 11.8743 23.3981 9.78006H12.2381V14.4094H18.7012C18.572 15.1478 18.2952 15.8525 17.8873 16.4814C17.4795 17.1102 16.9489 17.6504 16.3274 18.0694C15.5382 18.5915 14.6485 18.9428 13.7156 19.1007C12.7798 19.2747 11.82 19.2747 10.8843 19.1007C9.93591 18.9046 9.03874 18.5132 8.24993 17.9513C6.98271 17.0543 6.0312 15.7799 5.53118 14.3101C5.02271 12.8127 5.02271 11.1893 5.53118 9.69193C5.8871 8.64234 6.47549 7.68669 7.25243 6.89631C8.14154 5.97521 9.26718 5.3168 10.5058 4.99332C11.7445 4.66985 13.0484 4.6938 14.2743 5.06256C15.232 5.35654 16.1078 5.87019 16.8318 6.56256C17.5606 5.83756 18.2881 5.11068 19.0143 4.38193C19.3893 3.99006 19.7981 3.61693 20.1674 3.21568C19.0622 2.1872 17.765 1.38691 16.3499 0.860682C13.7731 -0.0749615 10.9536 -0.100106 8.36055 0.789432Z" fill="white"/>
|
||||
<path d="M8.3607 0.789367C10.9536 -0.100776 13.7731 -0.0762934 16.3501 0.858742C17.7654 1.38855 19.062 2.19269 20.1657 3.22499C19.7907 3.62624 19.3951 4.00124 19.0126 4.39124C18.2851 5.11749 17.5582 5.84124 16.832 6.56249C16.1079 5.87012 15.2321 5.35648 14.2745 5.06249C13.0489 4.69244 11.7451 4.66711 10.5061 4.98926C9.26712 5.31141 8.14079 5.96861 7.2507 6.88874C6.47377 7.67912 5.88538 8.63477 5.52945 9.68437L1.64258 6.67499C3.03384 3.91604 5.44273 1.80566 8.3607 0.789367Z" fill="#E33629"/>
|
||||
<path d="M0.611401 9.65654C0.820316 8.62116 1.16716 7.61847 1.64265 6.67529L5.52953 9.69217C5.02105 11.1896 5.02105 12.8129 5.52953 14.3103C4.23453 15.3103 2.9389 16.3153 1.64265 17.3253C0.452308 14.9559 0.0892746 12.2562 0.611401 9.65654Z" fill="#F8BD00"/>
|
||||
<path d="M12.2381 9.77783H23.3981C23.7799 11.8721 23.6766 14.026 23.0963 16.0741C22.5623 17.9575 21.5252 19.6597 20.0963 20.9978C18.8419 20.0191 17.5819 19.0478 16.3275 18.0691C16.9494 17.6496 17.4802 17.1089 17.8881 16.4793C18.296 15.8498 18.5726 15.1444 18.7013 14.4053H12.2381C12.2363 12.8641 12.2381 11.321 12.2381 9.77783Z" fill="#587DBD"/>
|
||||
<path d="M1.64062 17.3251C2.93687 16.3251 4.2325 15.3201 5.5275 14.3101C6.02851 15.7804 6.98138 17.0549 8.25 17.9513C9.04126 18.5106 9.94037 18.8988 10.89 19.0913C11.8257 19.2653 12.7855 19.2653 13.7212 19.0913C14.6542 18.9334 15.5439 18.5821 16.3331 18.0601C17.5875 19.0388 18.8475 20.0101 20.1019 20.9888C18.7292 22.237 17.0558 23.1073 15.2456 23.5144C13.2476 23.9841 11.1655 23.9616 9.17812 23.4488C7.60632 23.0291 6.13814 22.2893 4.86562 21.2757C3.51874 20.2063 2.41867 18.8588 1.64062 17.3251Z" fill="#319F43"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2781_85129">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
|
||||
<path d="M0 0h10.5v10.5H0V0z" fill="#F25022"/>
|
||||
<path d="M12.5 0H23v10.5H12.5V0z" fill="#7FBA00"/>
|
||||
<path d="M0 12.5h10.5V23H0V12.5z" fill="#00A4EF"/>
|
||||
<path d="M12.5 12.5H23V23H12.5V12.5z" fill="#FFB900"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 292 B |
@@ -3038,6 +3038,11 @@
|
||||
"enterEmail": "Enter your email",
|
||||
"enterPassword": "Enter your password",
|
||||
"loggingIn": "Logging In...",
|
||||
"username": "Username",
|
||||
"enterUsername": "Enter username",
|
||||
"useEmailInstead": "Login with email",
|
||||
"forgotPassword": "Forgot your password?",
|
||||
"logIn": "Log In",
|
||||
"signingIn": "Signing in...",
|
||||
"login": "Login",
|
||||
"or": "Or",
|
||||
@@ -3076,6 +3081,10 @@
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"passwordTooShort": "Password must be at least 6 characters long",
|
||||
"invalidEmail": "Please enter a valid email address",
|
||||
"nameRequired": "Name is required",
|
||||
"emailRequired": "Email is required",
|
||||
"passwordRequired": "Password is required",
|
||||
"confirmPasswordRequired": "Confirm password is required",
|
||||
"checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.",
|
||||
"accountCreatedSuccessfully": "Account created successfully! You can now sign in.",
|
||||
"unexpectedError": "Unexpected error: {{message}}"
|
||||
@@ -3974,6 +3983,34 @@
|
||||
"undoStorageError": "Undo completed but some files could not be saved to storage",
|
||||
"undoSuccess": "Operation undone successfully",
|
||||
"unsupported": "Unsupported",
|
||||
"signup": {
|
||||
"title": "Create an account",
|
||||
"subtitle": "Join Stirling PDF to get started",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm password",
|
||||
"enterName": "Enter your name",
|
||||
"enterEmail": "Enter your email",
|
||||
"enterPassword": "Enter your password",
|
||||
"confirmPasswordPlaceholder": "Confirm password",
|
||||
"or": "or",
|
||||
"creatingAccount": "Creating Account...",
|
||||
"signUp": "Sign Up",
|
||||
"alreadyHaveAccount": "Already have an account? Sign in",
|
||||
"pleaseFillAllFields": "Please fill in all fields",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"passwordTooShort": "Password must be at least 6 characters long",
|
||||
"invalidEmail": "Please enter a valid email address",
|
||||
"checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.",
|
||||
"accountCreatedSuccessfully": "Account created successfully! You can now sign in.",
|
||||
"unexpectedError": "Unexpected error: {{message}}",
|
||||
"useEmailInstead": "Use Email Instead",
|
||||
"nameRequired": "Name is required",
|
||||
"emailRequired": "Email is required",
|
||||
"passwordRequired": "Password is required",
|
||||
"confirmPasswordRequired": "Please confirm your password"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcomeModal": {
|
||||
"title": "Welcome to Stirling PDF!",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Suspense } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider";
|
||||
import { FileContextProvider } from "./contexts/FileContext";
|
||||
import { NavigationProvider } from "./contexts/NavigationContext";
|
||||
@@ -11,9 +12,15 @@ import { PreferencesProvider } from "./contexts/PreferencesContext";
|
||||
import { OnboardingProvider } from "./contexts/OnboardingContext";
|
||||
import { TourOrchestrationProvider } from "./contexts/TourOrchestrationContext";
|
||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import OnboardingTour from "./components/onboarding/OnboardingTour";
|
||||
|
||||
// Import auth components
|
||||
import { AuthProvider } from "./auth/UseSession";
|
||||
import Landing from "./routes/Landing";
|
||||
import Login from "./routes/Login";
|
||||
import Signup from "./routes/Signup";
|
||||
import AuthCallback from "./routes/AuthCallback";
|
||||
|
||||
// Import global styles
|
||||
import "./styles/tailwind.css";
|
||||
import "./styles/cookieconsent.css";
|
||||
@@ -44,35 +51,50 @@ const LoadingFallback = () => (
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<PreferencesProvider>
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<OnboardingProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<HomePage />
|
||||
<OnboardingTour />
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</OnboardingProvider>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* Auth routes - no FileContext or other providers needed */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
|
||||
{/* Main app routes - wrapped with all providers */}
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<OnboardingProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<Landing />
|
||||
<OnboardingTour />
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</OnboardingProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
</PreferencesProvider>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||
import { springAuth } from './springAuthClient';
|
||||
import type { Session, User, AuthError } from './springAuthClient';
|
||||
|
||||
/**
|
||||
* Auth Context Type
|
||||
* Simplified version without SaaS-specific features (credits, subscriptions)
|
||||
*/
|
||||
interface AuthContextType {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
error: AuthError | null;
|
||||
signOut: () => Promise<void>;
|
||||
refreshSession: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
session: null,
|
||||
user: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
refreshSession: async () => {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Auth Provider Component
|
||||
*
|
||||
* Manages authentication state and provides it to the entire app.
|
||||
* Integrates with Spring Security + JWT backend.
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
* Refresh current session
|
||||
*/
|
||||
const refreshSession = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.debug('[Auth] Refreshing session...');
|
||||
|
||||
const { data, error } = await springAuth.refreshSession();
|
||||
|
||||
if (error) {
|
||||
console.error('[Auth] Session refresh error:', error);
|
||||
setError(error);
|
||||
setSession(null);
|
||||
} else {
|
||||
console.debug('[Auth] Session refreshed successfully');
|
||||
setSession(data.session);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth] Unexpected error during session refresh:', err);
|
||||
setError(err as AuthError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Sign out user
|
||||
*/
|
||||
const signOut = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
console.debug('[Auth] Signing out...');
|
||||
|
||||
const { error } = await springAuth.signOut();
|
||||
|
||||
if (error) {
|
||||
console.error('[Auth] Sign out error:', error);
|
||||
setError(error);
|
||||
} else {
|
||||
console.debug('[Auth] Signed out successfully');
|
||||
setSession(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth] Unexpected error during sign out:', err);
|
||||
setError(err as AuthError);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Initialize auth on mount
|
||||
*/
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
console.debug('[Auth] Initializing auth...');
|
||||
|
||||
// First check if login is enabled
|
||||
const configResponse = await fetch('/api/v1/config/app-config');
|
||||
if (configResponse.ok) {
|
||||
const config = await configResponse.json();
|
||||
|
||||
// If login is disabled, skip authentication entirely
|
||||
if (config.enableLogin === false) {
|
||||
console.debug('[Auth] Login disabled - skipping authentication');
|
||||
if (mounted) {
|
||||
setSession(null);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Login is enabled, proceed with normal auth check
|
||||
const { data, error } = await springAuth.getSession();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (error) {
|
||||
console.error('[Auth] Initial session error:', error);
|
||||
setError(error);
|
||||
} else {
|
||||
console.debug('[Auth] Initial session loaded:', {
|
||||
hasSession: !!data.session,
|
||||
userId: data.session?.user?.id,
|
||||
email: data.session?.user?.email,
|
||||
});
|
||||
setSession(data.session);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth] 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 } } = springAuth.onAuthStateChange(
|
||||
async (event, newSession) => {
|
||||
if (!mounted) return;
|
||||
|
||||
console.debug('[Auth] Auth state change:', {
|
||||
event,
|
||||
hasSession: !!newSession,
|
||||
userId: newSession?.user?.id,
|
||||
email: newSession?.user?.email,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Schedule state update
|
||||
setTimeout(() => {
|
||||
if (mounted) {
|
||||
setSession(newSession);
|
||||
setError(null);
|
||||
|
||||
// Handle specific events
|
||||
if (event === 'SIGNED_OUT') {
|
||||
console.debug('[Auth] User signed out, clearing session');
|
||||
} else if (event === 'SIGNED_IN') {
|
||||
console.debug('[Auth] User signed in successfully');
|
||||
} else if (event === 'TOKEN_REFRESHED') {
|
||||
console.debug('[Auth] Token refreshed');
|
||||
} else if (event === 'USER_UPDATED') {
|
||||
console.debug('[Auth] User updated');
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value: AuthContextType = {
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
loading,
|
||||
error,
|
||||
signOut,
|
||||
refreshSession,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access auth context
|
||||
* Must be used within AuthProvider
|
||||
*/
|
||||
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
|
||||
* Can be used in development to monitor auth state
|
||||
*/
|
||||
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,
|
||||
});
|
||||
}, [auth.session, auth.user, auth.loading, auth.error]);
|
||||
|
||||
return auth;
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Spring Auth Client
|
||||
*
|
||||
* This client integrates with the Spring Security + JWT backend.
|
||||
* - Uses localStorage for JWT storage (sent via Authorization header)
|
||||
* - JWT validation handled server-side
|
||||
* - No email confirmation flow (auto-confirmed on registration)
|
||||
*/
|
||||
|
||||
// Auth types
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
enabled?: boolean;
|
||||
is_anonymous?: boolean;
|
||||
app_metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user: User;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
expires_at?: number;
|
||||
}
|
||||
|
||||
export interface AuthError {
|
||||
message: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
error: AuthError | null;
|
||||
}
|
||||
|
||||
export type AuthChangeEvent =
|
||||
| 'SIGNED_IN'
|
||||
| 'SIGNED_OUT'
|
||||
| 'TOKEN_REFRESHED'
|
||||
| 'USER_UPDATED';
|
||||
|
||||
type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => void;
|
||||
|
||||
class SpringAuthClient {
|
||||
private listeners: AuthChangeCallback[] = [];
|
||||
private sessionCheckInterval: NodeJS.Timeout | null = null;
|
||||
private readonly SESSION_CHECK_INTERVAL = 60000; // 1 minute
|
||||
private readonly TOKEN_REFRESH_THRESHOLD = 300000; // 5 minutes before expiry
|
||||
|
||||
constructor() {
|
||||
// Start periodic session validation
|
||||
this.startSessionMonitoring();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get CSRF token from cookie
|
||||
*/
|
||||
private getCsrfToken(): string | null {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (const cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=');
|
||||
if (name === 'XSRF-TOKEN') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session
|
||||
* JWT is stored in localStorage and sent via Authorization header
|
||||
*/
|
||||
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
||||
try {
|
||||
// Get JWT from localStorage
|
||||
const token = localStorage.getItem('stirling_jwt');
|
||||
|
||||
if (!token) {
|
||||
console.debug('[SpringAuth] getSession: No JWT in localStorage');
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
// Verify with backend
|
||||
const response = await fetch('/api/v1/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Token invalid or expired - clear it
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
console.debug('[SpringAuth] getSession: Not authenticated (status:', response.status, ')');
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Create session object
|
||||
const session: Session = {
|
||||
user: data.user,
|
||||
access_token: token,
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600 * 1000,
|
||||
};
|
||||
|
||||
console.debug('[SpringAuth] getSession: Session retrieved successfully');
|
||||
return { data: { session }, error: null };
|
||||
} catch (error) {
|
||||
console.error('[SpringAuth] getSession error:', error);
|
||||
// Clear potentially invalid token
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
return {
|
||||
data: { session: null },
|
||||
error: { message: error instanceof Error ? error.message : 'Unknown error' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signInWithPassword(credentials: {
|
||||
email: string;
|
||||
password: string;
|
||||
}): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', // Include cookies for CSRF
|
||||
body: JSON.stringify({
|
||||
username: credentials.email,
|
||||
password: credentials.password
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return { user: null, session: null, error: { message: error.error || 'Login failed' } };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const token = data.session.access_token;
|
||||
|
||||
// Store JWT in localStorage
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
console.log('[SpringAuth] JWT stored in localStorage');
|
||||
|
||||
const session: Session = {
|
||||
user: data.user,
|
||||
access_token: token,
|
||||
expires_in: data.session.expires_in,
|
||||
expires_at: Date.now() + data.session.expires_in * 1000,
|
||||
};
|
||||
|
||||
// Notify listeners
|
||||
this.notifyListeners('SIGNED_IN', session);
|
||||
|
||||
return { user: data.user, session, error: null };
|
||||
} catch (error) {
|
||||
console.error('[SpringAuth] signInWithPassword error:', error);
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
error: { message: error instanceof Error ? error.message : 'Login failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign up new user
|
||||
*/
|
||||
async signUp(credentials: {
|
||||
email: string;
|
||||
password: string;
|
||||
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
|
||||
}): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await fetch('/api/v1/user/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
username: credentials.email,
|
||||
password: credentials.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return { user: null, session: null, error: { message: error.error || 'Registration failed' } };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Note: Spring backend auto-confirms users (no email verification)
|
||||
// Return user but no session (user needs to login)
|
||||
return { user: data.user, session: null, error: null };
|
||||
} catch (error) {
|
||||
console.error('[SpringAuth] signUp error:', error);
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
error: { message: error instanceof Error ? error.message : 'Registration failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in with OAuth provider (GitHub, Google, etc.)
|
||||
* This redirects to the Spring OAuth2 authorization endpoint
|
||||
*/
|
||||
async signInWithOAuth(params: {
|
||||
provider: 'github' | 'google' | 'apple' | 'azure';
|
||||
options?: { redirectTo?: string; queryParams?: Record<string, any> };
|
||||
}): Promise<{ error: AuthError | null }> {
|
||||
try {
|
||||
// Redirect to Spring OAuth2 endpoint (Vite will proxy to backend)
|
||||
const redirectUrl = `/oauth2/authorization/${params.provider}`;
|
||||
console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl);
|
||||
// Use window.location.assign for full page navigation
|
||||
window.location.assign(redirectUrl);
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: { message: error instanceof Error ? error.message : 'OAuth redirect failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut(): Promise<{ error: AuthError | null }> {
|
||||
try {
|
||||
// Clear JWT from localStorage immediately
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
console.log('[SpringAuth] JWT removed from localStorage');
|
||||
|
||||
const csrfToken = this.getCsrfToken();
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
if (csrfToken) {
|
||||
headers['X-XSRF-TOKEN'] = csrfToken;
|
||||
}
|
||||
|
||||
// Notify backend (optional - mainly for session cleanup)
|
||||
await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
});
|
||||
|
||||
// Notify listeners
|
||||
this.notifyListeners('SIGNED_OUT', null);
|
||||
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
console.error('[SpringAuth] signOut error:', error);
|
||||
// Still remove token even if backend call fails
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
return {
|
||||
error: { message: error instanceof Error ? error.message : 'Sign out failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh session token
|
||||
*/
|
||||
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
||||
try {
|
||||
const currentToken = localStorage.getItem('stirling_jwt');
|
||||
|
||||
if (!currentToken) {
|
||||
return { data: { session: null }, error: { message: 'No token to refresh' } };
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${currentToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
return { data: { session: null }, error: { message: 'Token refresh failed' } };
|
||||
}
|
||||
|
||||
const refreshData = await response.json();
|
||||
const newToken = refreshData.access_token;
|
||||
|
||||
// Store new token
|
||||
localStorage.setItem('stirling_jwt', newToken);
|
||||
|
||||
// Get updated user info
|
||||
const userResponse = await fetch('/api/v1/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${newToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userResponse.ok) {
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
return { data: { session: null }, error: { message: 'Failed to get user info' } };
|
||||
}
|
||||
|
||||
const userData = await userResponse.json();
|
||||
const session: Session = {
|
||||
user: userData.user,
|
||||
access_token: newToken,
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600 * 1000,
|
||||
};
|
||||
|
||||
// Notify listeners
|
||||
this.notifyListeners('TOKEN_REFRESHED', session);
|
||||
|
||||
return { data: { session }, error: null };
|
||||
} catch (error) {
|
||||
console.error('[SpringAuth] refreshSession error:', error);
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
return {
|
||||
data: { session: null },
|
||||
error: { message: error instanceof Error ? error.message : 'Refresh failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to auth state changes
|
||||
*/
|
||||
onAuthStateChange(callback: AuthChangeCallback): { data: { subscription: { unsubscribe: () => void } } } {
|
||||
this.listeners.push(callback);
|
||||
|
||||
return {
|
||||
data: {
|
||||
subscription: {
|
||||
unsubscribe: () => {
|
||||
this.listeners = this.listeners.filter((cb) => cb !== callback);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private notifyListeners(event: AuthChangeEvent, session: Session | null) {
|
||||
// Use setTimeout to avoid calling callbacks synchronously
|
||||
setTimeout(() => {
|
||||
this.listeners.forEach((callback) => {
|
||||
try {
|
||||
callback(event, session);
|
||||
} catch (error) {
|
||||
console.error('[SpringAuth] Error in auth state change listener:', error);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private startSessionMonitoring() {
|
||||
// Periodically check session validity
|
||||
// Since we use HttpOnly cookies, we just need to check with the server
|
||||
this.sessionCheckInterval = setInterval(async () => {
|
||||
try {
|
||||
// Try to get current session
|
||||
const { data } = await this.getSession();
|
||||
|
||||
// If we have a session, proactively refresh if needed
|
||||
// (The server will handle token expiry, but we can be proactive)
|
||||
if (data.session) {
|
||||
const timeUntilExpiry = (data.session.expires_at || 0) - Date.now();
|
||||
|
||||
// Refresh if token expires soon
|
||||
if (timeUntilExpiry > 0 && timeUntilExpiry < this.TOKEN_REFRESH_THRESHOLD) {
|
||||
console.log('[SpringAuth] Proactively refreshing token');
|
||||
await this.refreshSession();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SpringAuth] Session monitoring error:', error);
|
||||
}
|
||||
}, this.SESSION_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
if (this.sessionCheckInterval) {
|
||||
clearInterval(this.sessionCheckInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const springAuth = new SpringAuthClient();
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
export const getCurrentUser = async () => {
|
||||
const { data } = await springAuth.getSession();
|
||||
return data.session?.user || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user is anonymous
|
||||
*/
|
||||
export const isUserAnonymous = (user: User | null) => {
|
||||
return user?.is_anonymous === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an anonymous user object for use when login is disabled
|
||||
* This provides a consistent User interface throughout the app
|
||||
*/
|
||||
export const createAnonymousUser = (): User => {
|
||||
return {
|
||||
id: 'anonymous',
|
||||
email: 'anonymous@local',
|
||||
username: 'Anonymous User',
|
||||
role: 'USER',
|
||||
enabled: true,
|
||||
is_anonymous: true,
|
||||
app_metadata: {
|
||||
provider: 'anonymous',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an anonymous session for use when login is disabled
|
||||
*/
|
||||
export const createAnonymousSession = (): Session => {
|
||||
return {
|
||||
user: createAnonymousUser(),
|
||||
access_token: '',
|
||||
expires_in: Number.MAX_SAFE_INTEGER,
|
||||
expires_at: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
};
|
||||
|
||||
// Export auth client as default for convenience
|
||||
export default springAuth;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Text, Center, Box, LoadingOverlay, Stack
|
||||
} from '@mantine/core';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import './dividerWithText/DividerWithText.css';
|
||||
|
||||
interface TextDividerProps {
|
||||
text?: string
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
variant?: 'default' | 'subcategory'
|
||||
respondsToDarkMode?: boolean
|
||||
opacity?: number
|
||||
}
|
||||
|
||||
export default function DividerWithText({ text, className = '', style, variant = 'default', respondsToDarkMode = true, opacity }: TextDividerProps) {
|
||||
const variantClass = variant === 'subcategory' ? 'subcategory' : '';
|
||||
const themeClass = respondsToDarkMode ? '' : 'force-light';
|
||||
const styleWithOpacity = opacity !== undefined ? { ...(style || {}), ['--text-divider-opacity' as any]: opacity } : style;
|
||||
|
||||
if (text) {
|
||||
return (
|
||||
<div
|
||||
className={`text-divider ${variantClass} ${themeClass} ${className}`}
|
||||
style={styleWithOpacity}
|
||||
>
|
||||
<div className="text-divider__rule" />
|
||||
<span className="text-divider__label">{text}</span>
|
||||
<div className="text-divider__rule" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-px my-2.5 ${themeClass} ${className}`}
|
||||
style={styleWithOpacity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,8 @@ interface FooterProps {
|
||||
}
|
||||
|
||||
export default function Footer({
|
||||
privacyPolicy = '/privacy',
|
||||
termsAndConditions = '/terms',
|
||||
privacyPolicy = 'https://www.stirling.com/legal/privacy-policy',
|
||||
termsAndConditions = 'https://www.stirling.com/legal/terms-of-service',
|
||||
accessibilityStatement = 'accessibility',
|
||||
analyticsEnabled = false
|
||||
}: FooterProps) {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
|
||||
type ImageSlide = { src: string; alt?: string; cornerModelUrl?: string; title?: string; subtitle?: string; followMouseTilt?: boolean; tiltMaxDeg?: number }
|
||||
|
||||
export default function LoginRightCarousel({
|
||||
imageSlides = [],
|
||||
showBackground = true,
|
||||
initialSeconds = 5,
|
||||
slideSeconds = 8,
|
||||
}: {
|
||||
imageSlides?: ImageSlide[]
|
||||
showBackground?: boolean
|
||||
initialSeconds?: number
|
||||
slideSeconds?: number
|
||||
}) {
|
||||
const totalSlides = imageSlides.length;
|
||||
const [index, setIndex] = useState(0);
|
||||
const mouse = useRef({ x: 0, y: 0 });
|
||||
|
||||
const durationsMs = useMemo(() => {
|
||||
if (imageSlides.length === 0) return [];
|
||||
return imageSlides.map((_, i) => (i === 0 ? (initialSeconds ?? slideSeconds) : slideSeconds) * 1000);
|
||||
}, [imageSlides, initialSeconds, slideSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (totalSlides <= 1) return;
|
||||
const timeout = setTimeout(() => {
|
||||
setIndex((i) => (i + 1) % totalSlides);
|
||||
}, durationsMs[index] ?? slideSeconds * 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [index, totalSlides, durationsMs, slideSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
mouse.current.y = (e.clientY / window.innerHeight) * 2 - 1;
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
return () => window.removeEventListener('mousemove', onMove);
|
||||
}, []);
|
||||
|
||||
function TiltImage({ src, alt, enabled, maxDeg = 6 }: { src: string; alt?: string; enabled: boolean; maxDeg?: number }) {
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = imgRef.current;
|
||||
if (!el) return;
|
||||
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
if (enabled) {
|
||||
const rotY = (mouse.current.x || 0) * maxDeg;
|
||||
const rotX = -(mouse.current.y || 0) * maxDeg;
|
||||
el.style.transform = `translateY(-2rem) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg)`;
|
||||
} else {
|
||||
el.style.transform = 'translateY(-2rem)';
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [enabled, maxDeg]);
|
||||
|
||||
return (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={alt ?? 'Carousel slide'}
|
||||
style={{
|
||||
maxWidth: '86%',
|
||||
maxHeight: '78%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: '18px',
|
||||
background: 'transparent',
|
||||
transform: 'translateY(-2rem)',
|
||||
transition: 'transform 80ms ease-out',
|
||||
willChange: 'transform',
|
||||
transformOrigin: '50% 50%',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', overflow: 'hidden', width: '100%', height: '100%' }}>
|
||||
{showBackground && (
|
||||
<img
|
||||
src={`${BASE_PATH}/Login/LoginBackgroundPanel.png`}
|
||||
alt="Background panel"
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Image slides */}
|
||||
{imageSlides.map((s, idx) => (
|
||||
<div
|
||||
key={s.src}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'opacity 600ms ease',
|
||||
opacity: index === idx ? 1 : 0,
|
||||
perspective: '900px',
|
||||
}}
|
||||
>
|
||||
{(s.title || s.subtitle) && (
|
||||
<div style={{ position: 'absolute', bottom: 24 + 32, left: 0, right: 0, textAlign: 'center', padding: '0 2rem', width: '100%' }}>
|
||||
{s.title && (
|
||||
<div style={{ fontSize: 20, fontWeight: 800, color: '#ffffff', textShadow: '0 2px 6px rgba(0,0,0,0.25)', marginBottom: 6 }}>{s.title}</div>
|
||||
)}
|
||||
{s.subtitle && (
|
||||
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.92)', textShadow: '0 1px 4px rgba(0,0,0,0.25)' }}>{s.subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<TiltImage src={s.src} alt={s.alt} enabled={index === idx && !!s.followMouseTilt} maxDeg={s.tiltMaxDeg ?? 6} />
|
||||
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Dot navigation */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: totalSlides }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
onClick={() => setIndex(i)}
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: i === index ? '#ffffff' : 'rgba(255,255,255,0.5)',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
|
||||
display: 'block',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import styles from './textInput/TextInput.module.css';
|
||||
* Props for the TextInput component
|
||||
*/
|
||||
export interface TextInputProps {
|
||||
/** The input ID (required) */
|
||||
id: string;
|
||||
/** The input name (required) */
|
||||
name: string;
|
||||
/** The input value (required) */
|
||||
value: string;
|
||||
/** Callback when input value changes (required) */
|
||||
@@ -36,6 +40,8 @@ export interface TextInputProps {
|
||||
}
|
||||
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
||||
id,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
@@ -76,6 +82,8 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
id={id}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Code, Group, Badge, Alert, Loader } from '@mantine/core';
|
||||
import { Stack, Text, Code, Group, Badge, Alert, Loader, Button } from '@mantine/core';
|
||||
import { useAppConfig } from '../../../../hooks/useAppConfig';
|
||||
import { useAuth } from '../../../../auth/UseSession';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const Overview: React.FC = () => {
|
||||
const { config, loading, error } = useAppConfig();
|
||||
const { signOut, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const renderConfigSection = (title: string, data: any) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
@@ -54,6 +58,15 @@ const Overview: React.FC = () => {
|
||||
SSOAutoLogin: config.SSOAutoLogin,
|
||||
} : null;
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
navigate('/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" py="md">
|
||||
@@ -74,10 +87,24 @@ const Overview: React.FC = () => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">Application Configuration</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Current application settings and configuration details.
|
||||
</Text>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||
<div>
|
||||
<Text fw={600} size="lg">Application Configuration</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Current application settings and configuration details.
|
||||
</Text>
|
||||
{user?.email && (
|
||||
<Text size="xs" c="dimmed" mt="0.25rem">
|
||||
Signed in as: {user.email}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{user && (
|
||||
<Button color="red" variant="filled" onClick={handleLogout}>
|
||||
Log out
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config && (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
|
||||
.text-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem; /* 12px */
|
||||
margin-top: 0.375rem; /* 6px */
|
||||
margin-bottom: 0.5rem; /* 8px */
|
||||
}
|
||||
|
||||
.text-divider .text-divider__rule {
|
||||
height: 0.0625rem; /* 1px */
|
||||
flex: 1 1 0%;
|
||||
background-color: rgb(var(--text-divider-rule-rgb, var(--gray-200)) / var(--text-divider-opacity, 1));
|
||||
}
|
||||
|
||||
.text-divider .text-divider__label {
|
||||
color: rgb(var(--text-divider-label-rgb, var(--gray-400)) / var(--text-divider-opacity, 1));
|
||||
font-size: 0.75rem; /* 12px */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-divider.subcategory {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.text-divider.subcategory .text-divider__rule {
|
||||
background-color: var(--tool-subcategory-rule-color);
|
||||
}
|
||||
|
||||
.text-divider.subcategory .text-divider__label {
|
||||
color: var(--tool-subcategory-text-color);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Force light theme colors regardless of dark mode */
|
||||
.text-divider.force-light .text-divider__rule {
|
||||
background-color: rgb(var(--text-divider-rule-rgb-light, var(--gray-200)) / var(--text-divider-opacity, 1));
|
||||
}
|
||||
|
||||
.text-divider.force-light .text-divider__label {
|
||||
color: rgb(var(--text-divider-label-rgb-light, var(--gray-400)) / var(--text-divider-opacity, 1));
|
||||
}
|
||||
|
||||
.text-divider.subcategory.force-light .text-divider__rule {
|
||||
background-color: var(--tool-subcategory-rule-color-light);
|
||||
}
|
||||
|
||||
.text-divider.subcategory.force-light .text-divider__label {
|
||||
color: var(--tool-subcategory-text-color-light);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
|
||||
export type LoginCarouselSlide = {
|
||||
src: string
|
||||
alt?: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
cornerModelUrl?: string
|
||||
followMouseTilt?: boolean
|
||||
tiltMaxDeg?: number
|
||||
}
|
||||
|
||||
export const loginSlides: LoginCarouselSlide[] = [
|
||||
{
|
||||
src: `${BASE_PATH}/Login/Firstpage.png`,
|
||||
alt: 'Stirling PDF overview',
|
||||
title: 'Your one-stop-shop for all your PDF needs.',
|
||||
subtitle:
|
||||
'A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.',
|
||||
followMouseTilt: true,
|
||||
tiltMaxDeg: 5,
|
||||
},
|
||||
{
|
||||
src: `${BASE_PATH}/Login/AddToPDF.png`,
|
||||
alt: 'Edit PDFs',
|
||||
title: 'Edit PDFs to display/secure the information you want',
|
||||
subtitle:
|
||||
'With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.',
|
||||
followMouseTilt: true,
|
||||
tiltMaxDeg: 5,
|
||||
},
|
||||
{
|
||||
src: `${BASE_PATH}/Login/SecurePDF.png`,
|
||||
alt: 'Secure PDFs',
|
||||
title: 'Protect sensitive information in your PDFs',
|
||||
subtitle:
|
||||
'Add passwords, redact content, and manage certificates with ease.',
|
||||
followMouseTilt: true,
|
||||
tiltMaxDeg: 5,
|
||||
},
|
||||
];
|
||||
|
||||
export default loginSlides;
|
||||
@@ -33,32 +33,12 @@
|
||||
|
||||
/* Rainbow mode container */
|
||||
.quick-access-bar-main.rainbow-mode {
|
||||
background: linear-gradient(180deg, rgba(255, 0, 136, 0.65), rgba(0, 191, 255, 0.55), rgba(0, 255, 170, 0.65)),
|
||||
radial-gradient(circle at 20% 15%, rgba(255, 255, 255, 0.35), transparent 55%);
|
||||
background-size: 200% 200%, 120% 120%;
|
||||
animation: rainbowBackground 14s linear infinite;
|
||||
background-color: var(--bg-muted);
|
||||
width: 5rem;
|
||||
min-width: 5rem;
|
||||
max-width: 5rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
border-radius: 2.25rem;
|
||||
box-shadow: 0 0 25px rgba(255, 255, 255, 0.25), 0 0 45px rgba(255, 0, 136, 0.25);
|
||||
border: 2px solid rgba(255, 255, 255, 0.55);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.quick-access-bar-main.rainbow-mode::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border-radius: 2rem;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
box-shadow: inset 0 0 20px rgba(255, 255, 255, 0.35);
|
||||
mix-blend-mode: screen;
|
||||
animation: rainbowPulse 3.5s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Header padding */
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Slider, Text, Group, NumberInput } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolRegistryEntry, getSubcategoryLabel, getSubcategoryColor, getSubcategoryIcon } from '../../data/toolsTaxonomy';
|
||||
@@ -104,7 +104,7 @@ const FullscreenToolList = ({
|
||||
{showRecentFavorites && (
|
||||
<>
|
||||
{favoriteToolItems.length > 0 && (
|
||||
<section
|
||||
<section
|
||||
className="tool-panel__fullscreen-group tool-panel__fullscreen-group--special"
|
||||
style={{
|
||||
borderColor: 'var(--fullscreen-border-favorites)',
|
||||
@@ -146,7 +146,7 @@ const FullscreenToolList = ({
|
||||
)}
|
||||
|
||||
{recommendedItems.length > 0 && (
|
||||
<section
|
||||
<section
|
||||
className="tool-panel__fullscreen-group tool-panel__fullscreen-group--special"
|
||||
style={{
|
||||
borderColor: 'var(--fullscreen-border-recommended)',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { usePreferences } from '../../contexts/PreferencesContext';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AddPageNumbersParameters } from './useAddPageNumbersParameters';
|
||||
import { pdfWorkerManager } from '../../../services/pdfWorkerManager';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration';
|
||||
import ObscuredOverlay from '../../shared/ObscuredOverlay';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
import AdjustContrastBasicSettings from './AdjustContrastBasicSettings';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Text, Stack, Group, Card, Progress } from "@mantine/core";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
|
||||
@@ -82,15 +82,17 @@ const ToolSearch = ({
|
||||
}, [autoFocus]);
|
||||
|
||||
const searchInput = (
|
||||
<TextInput
|
||||
ref={searchRef}
|
||||
value={value}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
|
||||
icon={hideIcon ? undefined : <LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />}
|
||||
autoComplete="off"
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
<TextInput
|
||||
id="tool-search-input"
|
||||
name="tool-search-input"
|
||||
ref={searchRef}
|
||||
value={value}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
|
||||
icon={hideIcon ? undefined : <LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />}
|
||||
autoComplete="off"
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
);
|
||||
|
||||
if (mode === "filter") {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './styles.css';
|
||||
import FieldBlock from './FieldBlock';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Divider, Group, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SignatureValidationSignature } from '../../../../types/validateSignature';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Badge, Popover, Text } from '@mantine/core';
|
||||
import './styles.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import './styles.css';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createPluginRegistration } from '@embedpdf/core';
|
||||
import { EmbedPDF } from '@embedpdf/core/react';
|
||||
import { usePdfiumEngine } from '@embedpdf/engines/react';
|
||||
@@ -316,4 +316,4 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
</EmbedPDF>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { usePreferences } from './PreferencesContext';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useAuth } from '../auth/UseSession';
|
||||
|
||||
interface OnboardingContextValue {
|
||||
isOpen: boolean;
|
||||
@@ -18,6 +19,7 @@ const OnboardingContext = createContext<OnboardingContextValue | undefined>(unde
|
||||
|
||||
export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { session, loading } = useAuth();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [showWelcomeModal, setShowWelcomeModal] = useState(false);
|
||||
@@ -26,11 +28,16 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
// Auto-show welcome modal for first-time users after preferences load
|
||||
// Only show after user has seen the tool panel mode prompt
|
||||
// Also, don't show tour on mobile devices because it feels clunky
|
||||
// IMPORTANT: Only show welcome modal if user is authenticated or login is disabled
|
||||
useEffect(() => {
|
||||
if (!preferences.hasCompletedOnboarding && preferences.toolPanelModePromptSeen && !isMobile) {
|
||||
setShowWelcomeModal(true);
|
||||
if (!loading && !preferences.hasCompletedOnboarding && preferences.toolPanelModePromptSeen && !isMobile) {
|
||||
// Only show welcome modal if user is authenticated (session exists)
|
||||
// This prevents the modal from showing on login screens when security is enabled
|
||||
if (session) {
|
||||
setShowWelcomeModal(true);
|
||||
}
|
||||
}
|
||||
}, [preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, isMobile]);
|
||||
}, [preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, isMobile, session, loading]);
|
||||
|
||||
const startTour = useCallback(() => {
|
||||
setCurrentStep(0);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { ToolId } from '../types/toolId';
|
||||
import type { ToolRegistry } from '../data/toolsTaxonomy';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import LocalIcon from "../components/shared/LocalIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SplitPdfPanel from "../tools/Split";
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Helper to get JWT from localStorage for Authorization header
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const token = localStorage.getItem('stirling_jwt');
|
||||
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
baseUrl?: string;
|
||||
contextPath?: string;
|
||||
@@ -46,7 +52,9 @@ export function useAppConfig(): UseAppConfigReturn {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch('/api/v1/config/app-config');
|
||||
const response = await fetch('/api/v1/config/app-config', {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Helper to get JWT from localStorage for Authorization header
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const token = localStorage.getItem('stirling_jwt');
|
||||
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if a specific endpoint is enabled
|
||||
*/
|
||||
@@ -24,7 +30,9 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`);
|
||||
const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to check endpoint: ${response.status} ${response.statusText}`);
|
||||
@@ -80,7 +88,9 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
// Use batch API for efficiency
|
||||
const endpointsParam = endpoints.join(',');
|
||||
|
||||
const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
|
||||
const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/UseSession';
|
||||
|
||||
/**
|
||||
* OAuth Callback Handler
|
||||
*
|
||||
* This component is rendered after OAuth providers (GitHub, Google, etc.) redirect back.
|
||||
* The JWT is passed in the URL fragment (#access_token=...) by the Spring backend.
|
||||
* We extract it, store in localStorage, and redirect to the home page.
|
||||
*/
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const { refreshSession } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
console.log('[AuthCallback] Handling OAuth callback...');
|
||||
|
||||
// Extract JWT from URL fragment (#access_token=...)
|
||||
const hash = window.location.hash.substring(1); // Remove '#'
|
||||
const params = new URLSearchParams(hash);
|
||||
const token = params.get('access_token');
|
||||
|
||||
if (!token) {
|
||||
console.error('[AuthCallback] No access_token in URL fragment');
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - no token received.' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Store JWT in localStorage
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
console.log('[AuthCallback] JWT stored in localStorage');
|
||||
|
||||
// Refresh session to load user info into state
|
||||
await refreshSession();
|
||||
|
||||
console.log('[AuthCallback] Session refreshed, redirecting to home');
|
||||
|
||||
// Clear the hash from URL and redirect to home page
|
||||
navigate('/', { replace: true });
|
||||
} catch (error) {
|
||||
console.error('[AuthCallback] Error:', error);
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed. Please try again.' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [navigate, refreshSession]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh'
|
||||
}}>
|
||||
<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">
|
||||
Completing authentication...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/UseSession';
|
||||
import { useAppConfig } from '../hooks/useAppConfig';
|
||||
import HomePage from '../pages/HomePage';
|
||||
import Login from './Login';
|
||||
|
||||
/**
|
||||
* Landing component - Smart router based on authentication status
|
||||
*
|
||||
* If login is disabled: Show HomePage directly (anonymous mode)
|
||||
* If user is authenticated: Show HomePage
|
||||
* If user is not authenticated: Show Login or redirect to /login
|
||||
*/
|
||||
export default function Landing() {
|
||||
const { session, loading: authLoading } = useAuth();
|
||||
const { config, loading: configLoading } = useAppConfig();
|
||||
const location = useLocation();
|
||||
|
||||
const loading = authLoading || configLoading;
|
||||
|
||||
console.log('[Landing] State:', {
|
||||
pathname: location.pathname,
|
||||
loading,
|
||||
hasSession: !!session,
|
||||
loginEnabled: config?.enableLogin,
|
||||
});
|
||||
|
||||
// Show loading while checking auth and config
|
||||
if (loading) {
|
||||
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">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If login is disabled, show app directly (anonymous mode)
|
||||
if (config?.enableLogin === false) {
|
||||
console.debug('[Landing] Login disabled - showing app in anonymous mode');
|
||||
return <HomePage />;
|
||||
}
|
||||
|
||||
// If we have a session, show the main app
|
||||
if (session) {
|
||||
return <HomePage />;
|
||||
}
|
||||
|
||||
// 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 }} />;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { springAuth } from '../auth/springAuthClient';
|
||||
import { useAuth } from '../auth/UseSession';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentMeta } from '../hooks/useDocumentMeta';
|
||||
import AuthLayout from './authShared/AuthLayout';
|
||||
|
||||
// Import login components
|
||||
import LoginHeader from './login/LoginHeader';
|
||||
import ErrorMessage from './login/ErrorMessage';
|
||||
import EmailPasswordForm from './login/EmailPasswordForm';
|
||||
import OAuthButtons from './login/OAuthButtons';
|
||||
import DividerWithText from '../components/shared/DividerWithText';
|
||||
import LoggedInState from './login/LoggedInState';
|
||||
import { BASE_PATH } from '../constants/app';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { session, loading } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = 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 = window.location.origin + BASE_PATH;
|
||||
|
||||
// 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);
|
||||
|
||||
console.log(`[Login] Signing in with ${provider}`);
|
||||
|
||||
// Redirect to Spring OAuth2 endpoint
|
||||
const { error } = await springAuth.signInWithOAuth({
|
||||
provider,
|
||||
options: { redirectTo: `${BASE_PATH}/auth/callback` }
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(`[Login] ${provider} error:`, error);
|
||||
setError(t('login.failedToSignIn', { provider, message: error.message }) || `Failed to sign in with ${provider}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Login] Unexpected error:`, err);
|
||||
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred');
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signInWithEmail = async () => {
|
||||
if (!email || !password) {
|
||||
setError(t('login.pleaseEnterBoth') || 'Please enter both email and password');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningIn(true);
|
||||
setError(null);
|
||||
|
||||
console.log('[Login] Signing in with email:', email);
|
||||
|
||||
const { user, session, error } = await springAuth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
password: password
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('[Login] Email sign in error:', error);
|
||||
setError(error.message);
|
||||
} else if (user && session) {
|
||||
console.log('[Login] Email sign in successful');
|
||||
// Auth state will update automatically and Landing will redirect to home
|
||||
// No need to navigate manually here
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Login] Unexpected error:', err);
|
||||
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred');
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPassword = () => {
|
||||
navigate('/auth/reset');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('login.login') || 'Sign in'} />
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* OAuth first */}
|
||||
<OAuthButtons
|
||||
onProviderClick={signInWithProvider}
|
||||
isSubmitting={isSigningIn}
|
||||
layout="vertical"
|
||||
/>
|
||||
|
||||
{/* 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(true)}
|
||||
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', 'Login with email')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showEmailForm && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={password}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
onSubmit={signInWithEmail}
|
||||
isSubmitting={isSigningIn}
|
||||
submitButtonText={isSigningIn ? (t('login.loggingIn') || 'Signing in...') : (t('login.login') || 'Sign in')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 signup link */}
|
||||
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
|
||||
|
||||
<div style={{ textAlign: 'center', margin: '0.5rem 0 0.25rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/signup')}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t('signup.signUp', 'Sign up')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentMeta } from '../hooks/useDocumentMeta';
|
||||
import AuthLayout from './authShared/AuthLayout';
|
||||
import './authShared/auth.css';
|
||||
import { BASE_PATH } from '../constants/app';
|
||||
|
||||
// Import signup components
|
||||
import LoginHeader from './login/LoginHeader';
|
||||
import ErrorMessage from './login/ErrorMessage';
|
||||
import DividerWithText from '../components/shared/DividerWithText';
|
||||
import SignupForm from './signup/SignupForm';
|
||||
import { useSignupFormValidation, SignupFieldErrors } from './signup/SignupFormValidation';
|
||||
import { useAuthService } from './signup/AuthService';
|
||||
|
||||
export default function Signup() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [isSigningUp, setIsSigningUp] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({});
|
||||
|
||||
const baseUrl = window.location.origin + BASE_PATH;
|
||||
|
||||
// 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 } = useAuthService();
|
||||
|
||||
const handleSignUp = async () => {
|
||||
const validation = validateSignupForm(email, password, confirmPassword);
|
||||
if (!validation.isValid) {
|
||||
setError(validation.error);
|
||||
setFieldErrors(validation.fieldErrors || {});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningUp(true);
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
const result = await signUp(email, password, '');
|
||||
|
||||
if (result.user) {
|
||||
// Show success message and redirect to login
|
||||
setError(null);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('signup.title', 'Create an account')} subtitle={t('signup.subtitle', 'Join Stirling PDF')} />
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* Signup form - shown immediately */}
|
||||
<SignupForm
|
||||
email={email}
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
setConfirmPassword={setConfirmPassword}
|
||||
onSubmit={handleSignUp}
|
||||
isSubmitting={isSigningUp}
|
||||
fieldErrors={fieldErrors}
|
||||
showName={false}
|
||||
showTerms={false}
|
||||
/>
|
||||
|
||||
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
|
||||
|
||||
{/* Bottom row - centered */}
|
||||
<div style={{ textAlign: 'center', margin: '0.5rem 0 0.25rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/login')}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t('login.logIn', 'Log In')}
|
||||
</button>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.authCardTwoColumns {
|
||||
width: min(73.75rem, 96vw);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.authLeftPanel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.authLeftPanel::-webkit-scrollbar {
|
||||
display: none; /* WebKit browsers (Chrome, Safari, Edge) */
|
||||
}
|
||||
|
||||
.authContent {
|
||||
max-width: 26.25rem; /* 420px */
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import LoginRightCarousel from '../../components/shared/LoginRightCarousel';
|
||||
import loginSlides from '../../components/shared/loginSlides';
|
||||
import styles from './AuthLayout.module.css';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hideRightPanel, setHideRightPanel] = useState(false);
|
||||
|
||||
// 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
|
||||
ref={cardRef}
|
||||
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ''}`}
|
||||
style={{ marginBottom: 'auto' }}
|
||||
>
|
||||
<div className={styles.authLeftPanel}>
|
||||
<div className={styles.authContent}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{!hideRightPanel && (
|
||||
<LoginRightCarousel imageSlides={loginSlides} initialSeconds={5} slideSeconds={8} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
.auth-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem; /* 8px */
|
||||
margin-bottom: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
.auth-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem; /* 4px */
|
||||
}
|
||||
|
||||
.auth-label {
|
||||
font-size: 0.875rem; /* 14px */
|
||||
color: var(--auth-label-text-light-only);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem; /* 10px 12px */
|
||||
border: 1px solid var(--auth-input-border-light-only);
|
||||
border-radius: 0.625rem; /* 10px */
|
||||
font-size: 0.875rem; /* 14px */
|
||||
background-color: var(--auth-input-bg-light-only);
|
||||
color: var(--auth-input-text-light-only);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.auth-input:focus {
|
||||
border-color: var(--auth-border-focus-light-only);
|
||||
box-shadow: 0 0 0 3px var(--auth-focus-ring-light-only);
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem; /* 10px 12px */
|
||||
border: none;
|
||||
border-radius: 0.625rem; /* 10px */
|
||||
background-color: var(--auth-button-bg-light-only);
|
||||
color: var(--auth-button-text-light-only);
|
||||
font-size: 0.875rem; /* 14px */
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem; /* 12px */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-toggle-wrapper {
|
||||
text-align: center;
|
||||
margin-bottom: 0.625rem; /* 10px */
|
||||
}
|
||||
|
||||
.auth-toggle-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--auth-label-text-light-only);
|
||||
font-size: 0.875rem; /* 14px */
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-toggle-link:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-magic-row {
|
||||
display: flex;
|
||||
gap: 0.5rem; /* 8px */
|
||||
margin-bottom: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
.auth-magic-row .auth-input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.auth-magic-button {
|
||||
padding: 0.875rem 1rem; /* 14px 16px */
|
||||
border: none;
|
||||
border-radius: 0.625rem; /* 10px */
|
||||
background-color: var(--auth-magic-button-bg-light-only);
|
||||
color: var(--auth-magic-button-text-light-only);
|
||||
font-size: 0.875rem; /* 14px */
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-magic-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-terms {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem; /* 8px */
|
||||
margin-bottom: 0.5rem; /* 8px */
|
||||
}
|
||||
|
||||
.auth-checkbox {
|
||||
width: 1rem; /* 16px */
|
||||
height: 1rem; /* 16px */
|
||||
accent-color: #AF3434;
|
||||
}
|
||||
|
||||
.auth-terms-label {
|
||||
font-size: 0.75rem; /* 12px */
|
||||
color: var(--auth-label-text-light-only);
|
||||
}
|
||||
|
||||
.auth-terms-label a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.auth-confirm {
|
||||
overflow: hidden;
|
||||
transition: max-height 240ms ease, opacity 200ms ease;
|
||||
}
|
||||
|
||||
/* OAuth Button Styles */
|
||||
.oauth-container-icons {
|
||||
display: flex;
|
||||
margin-bottom: 0.625rem; /* 10px */
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.oauth-container-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem; /* 12px */
|
||||
margin-bottom: 0.625rem; /* 10px */
|
||||
}
|
||||
|
||||
.oauth-container-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
.oauth-button-icon {
|
||||
width: 3.75rem; /* 60px */
|
||||
height: 3.75rem; /* 60px */
|
||||
border-radius: 0.875rem; /* 14px */
|
||||
border: 1px solid var(--auth-input-border-light-only);
|
||||
background: var(--auth-card-bg-light-only);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); /* 0 2px 6px */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.oauth-button-icon:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.oauth-button-grid {
|
||||
width: 100%;
|
||||
padding: 1rem; /* 16px */
|
||||
border-radius: 0.875rem; /* 14px */
|
||||
border: 1px solid var(--auth-input-border-light-only);
|
||||
background: var(--auth-card-bg-light-only);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); /* 0 2px 6px */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.oauth-button-grid:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.oauth-button-vertical {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem 1rem; /* 16px 16px */
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.75rem; /* 12px */
|
||||
background-color: var(--auth-card-bg-light-only);
|
||||
font-size: 1rem; /* 16px */
|
||||
font-weight: 500;
|
||||
color: var(--auth-text-primary-light-only);
|
||||
cursor: pointer;
|
||||
gap: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
.oauth-button-vertical:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.oauth-icon-small {
|
||||
width: 1.75rem; /* 28px */
|
||||
height: 1.75rem; /* 28px */
|
||||
display: block;
|
||||
}
|
||||
|
||||
.oauth-icon-medium {
|
||||
width: 1.75rem; /* 28px */
|
||||
height: 1.75rem; /* 28px */
|
||||
display: block;
|
||||
}
|
||||
|
||||
.oauth-icon-tiny {
|
||||
width: 1.25rem; /* 20px */
|
||||
height: 1.25rem; /* 20px */
|
||||
}
|
||||
|
||||
/* Login Header Styles */
|
||||
.login-header {
|
||||
margin-bottom: 1rem; /* 16px */
|
||||
margin-top: 0.5rem; /* 8px */
|
||||
}
|
||||
|
||||
.login-header-logos {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem; /* 12px */
|
||||
margin-bottom: 1.25rem; /* 20px */
|
||||
}
|
||||
|
||||
.login-logo-icon {
|
||||
width: 2.5rem; /* 40px */
|
||||
height: 2.5rem; /* 40px */
|
||||
border-radius: 0.5rem; /* 8px */
|
||||
}
|
||||
|
||||
.login-logo-text {
|
||||
height: 1.5rem; /* 24px */
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 2rem; /* 32px */
|
||||
font-weight: 800;
|
||||
color: var(--auth-text-primary-light-only);
|
||||
margin: 0 0 0.375rem; /* 0 0 6px */
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: var(--auth-text-secondary-light-only);
|
||||
font-size: 0.875rem; /* 14px */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Navigation Link Styles */
|
||||
.navigation-link-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.navigation-link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--auth-label-text-light-only);
|
||||
font-size: 0.875rem; /* 14px */
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.navigation-link-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Message Styles */
|
||||
.error-message {
|
||||
padding: 1rem; /* 16px */
|
||||
background-color: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 0.5rem; /* 8px */
|
||||
margin-bottom: 1.5rem; /* 24px */
|
||||
}
|
||||
|
||||
.error-message-text {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
padding: 1rem; /* 16px */
|
||||
background-color: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 0.5rem; /* 8px */
|
||||
margin-bottom: 1.5rem; /* 24px */
|
||||
}
|
||||
|
||||
.success-message-text {
|
||||
color: #059669;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Field-level error styles */
|
||||
.auth-field-error {
|
||||
color: #dc2626;
|
||||
font-size: 0.6875rem; /* 11px */
|
||||
margin-top: 0.125rem; /* 2px */
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.auth-input-error {
|
||||
border-color: #dc2626 !important;
|
||||
}
|
||||
|
||||
.auth-input-error:focus {
|
||||
border-color: #dc2626 !important;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1) !important;
|
||||
}
|
||||
|
||||
/* Shared auth styles extracted from inline */
|
||||
.auth-section {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.auth-section-sm {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.auth-bottom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.auth-bottom-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-link-black {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.auth-dot-black {
|
||||
opacity: 0.5;
|
||||
padding: 0 0.5rem;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Email login button - red CTA style matching SaaS version */
|
||||
.auth-cta-button {
|
||||
background-color: #AF3434 !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.auth-cta-button:hover:not(:disabled) {
|
||||
background-color: #9a2e2e !important;
|
||||
}
|
||||
|
||||
.auth-cta-button:disabled {
|
||||
background-color: #AF3434 !important;
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import '../authShared/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.username', 'Username')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="text"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
placeholder={t('login.enterUsername', 'Enter username')}
|
||||
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,13 @@
|
||||
interface ErrorMessageProps {
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export default function ErrorMessage({ error }: ErrorMessageProps) {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className="error-message">
|
||||
<p className="error-message-text">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../auth/UseSession';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function LoggedInState() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f3f4f6',
|
||||
padding: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.1)',
|
||||
padding: '32px'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>✅</div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 'bold', color: '#059669', marginBottom: '8px' }}>
|
||||
{t('login.youAreLoggedIn')}
|
||||
</h1>
|
||||
<p style={{ color: '#6b7280', fontSize: '14px' }}>
|
||||
{t('login.email')}: {user?.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: '16px' }}>
|
||||
<p style={{ color: '#6b7280', fontSize: '14px' }}>
|
||||
Redirecting to home...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
|
||||
interface LoginHeaderProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
export default function LoginHeader({ title, subtitle }: LoginHeaderProps) {
|
||||
return (
|
||||
<div className="login-header">
|
||||
<div className="login-header-logos">
|
||||
<img src={`${BASE_PATH}/logo192.png`} alt="Logo" className="login-logo-icon" />
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`} alt="Stirling PDF" className="login-logo-text" />
|
||||
</div>
|
||||
<h1 className="login-title">{title}</h1>
|
||||
{subtitle && (
|
||||
<p className="login-subtitle">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
interface NavigationLinkProps {
|
||||
onClick: () => void
|
||||
text: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export default function NavigationLink({ onClick, text, isDisabled = false }: NavigationLinkProps) {
|
||||
return (
|
||||
<div className="navigation-link-container">
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
className="navigation-link-button"
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
|
||||
// OAuth provider configuration
|
||||
const oauthProviders = [
|
||||
{ id: 'google', label: 'Google', file: 'google.svg', isDisabled: false },
|
||||
{ id: 'github', label: 'GitHub', file: 'github.svg', isDisabled: false },
|
||||
{ id: 'apple', label: 'Apple', file: 'apple.svg', isDisabled: true },
|
||||
{ id: 'azure', label: 'Microsoft', file: 'microsoft.svg', isDisabled: true }
|
||||
];
|
||||
|
||||
interface OAuthButtonsProps {
|
||||
onProviderClick: (provider: 'github' | 'google' | 'apple' | 'azure') => void
|
||||
isSubmitting: boolean
|
||||
layout?: 'vertical' | 'grid' | 'icons'
|
||||
}
|
||||
|
||||
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical' }: OAuthButtonsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Filter out disabled providers - don't show them at all
|
||||
const enabledProviders = oauthProviders.filter(p => !p.isDisabled);
|
||||
|
||||
if (layout === 'icons') {
|
||||
return (
|
||||
<div className="oauth-container-icons">
|
||||
{enabledProviders.map((p) => (
|
||||
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
|
||||
<button
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting}
|
||||
className="oauth-button-icon"
|
||||
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
|
||||
>
|
||||
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-small"/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (layout === 'grid') {
|
||||
return (
|
||||
<div className="oauth-container-grid">
|
||||
{enabledProviders.map((p) => (
|
||||
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
|
||||
<button
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting}
|
||||
className="oauth-button-grid"
|
||||
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
|
||||
>
|
||||
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-medium"/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="oauth-container-vertical">
|
||||
{enabledProviders.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting}
|
||||
className="oauth-button-vertical"
|
||||
title={p.label}
|
||||
>
|
||||
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-tiny" />
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { springAuth } from '../../auth/springAuthClient';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
|
||||
export const useAuthService = () => {
|
||||
|
||||
const signUp = async (
|
||||
email: string,
|
||||
password: string,
|
||||
name: string
|
||||
) => {
|
||||
console.log('[Signup] Creating account for:', email);
|
||||
|
||||
const { user, session, error } = await springAuth.signUp({
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
options: {
|
||||
data: { full_name: name },
|
||||
emailRedirectTo: `${BASE_PATH}/auth/callback`
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('[Signup] Sign up error:', error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
console.log('[Signup] Sign up successful:', user);
|
||||
return {
|
||||
user: user,
|
||||
session: session,
|
||||
requiresEmailConfirmation: user && !session
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Unknown error occurred during signup');
|
||||
};
|
||||
|
||||
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
|
||||
const { error } = await springAuth.signInWithOAuth({
|
||||
provider,
|
||||
options: { redirectTo: `${BASE_PATH}/auth/callback` }
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
signUp,
|
||||
signInWithProvider
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect } from 'react';
|
||||
import '../authShared/auth.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SignupFieldErrors } from './SignupFormValidation';
|
||||
|
||||
interface SignupFormProps {
|
||||
name?: string
|
||||
email: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
agree?: boolean
|
||||
setName?: (name: string) => void
|
||||
setEmail: (email: string) => void
|
||||
setPassword: (password: string) => void
|
||||
setConfirmPassword: (password: string) => void
|
||||
setAgree?: (agree: boolean) => void
|
||||
onSubmit: () => void
|
||||
isSubmitting: boolean
|
||||
fieldErrors?: SignupFieldErrors
|
||||
showName?: boolean
|
||||
showTerms?: boolean
|
||||
}
|
||||
|
||||
export default function SignupForm({
|
||||
name = '',
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
agree = true,
|
||||
setName,
|
||||
setEmail,
|
||||
setPassword,
|
||||
setConfirmPassword,
|
||||
setAgree,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
fieldErrors = {},
|
||||
showName = false,
|
||||
showTerms = false
|
||||
}: SignupFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const showConfirm = password.length >= 4;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showConfirm && confirmPassword) {
|
||||
setConfirmPassword('');
|
||||
}
|
||||
}, [showConfirm, confirmPassword, setConfirmPassword]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="auth-fields">
|
||||
{showName && (
|
||||
<div className="auth-field">
|
||||
<label htmlFor="name" className="auth-label">{t('signup.name')}</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
placeholder={t('signup.enterName')}
|
||||
value={name}
|
||||
onChange={(e) => setName?.(e.target.value)}
|
||||
className={`auth-input ${fieldErrors.name ? 'auth-input-error' : ''}`}
|
||||
/>
|
||||
{fieldErrors.name && (
|
||||
<div className="auth-field-error">{fieldErrors.name}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="auth-field">
|
||||
<label htmlFor="email" className="auth-label">{t('signup.email')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
placeholder={t('signup.enterEmail')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
|
||||
className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`}
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<div className="auth-field-error">{fieldErrors.email}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
|
||||
className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`}
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<div className="auth-field-error">{fieldErrors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden={!showConfirm}
|
||||
className="auth-confirm"
|
||||
style={{ maxHeight: showConfirm ? 96 : 0, opacity: showConfirm ? 1 : 0 }}
|
||||
>
|
||||
<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)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
|
||||
className={`auth-input ${fieldErrors.confirmPassword ? 'auth-input-error' : ''}`}
|
||||
/>
|
||||
{fieldErrors.confirmPassword && (
|
||||
<div className="auth-field-error">{fieldErrors.confirmPassword}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terms - only show if showTerms is true */}
|
||||
{showTerms && (
|
||||
<div className="auth-terms">
|
||||
<input
|
||||
id="agree"
|
||||
type="checkbox"
|
||||
checked={agree}
|
||||
onChange={(e) => setAgree?.(e.target.checked)}
|
||||
className="auth-checkbox"
|
||||
/>
|
||||
<label htmlFor="agree" className="auth-terms-label">
|
||||
{t("legal.iAgreeToThe", 'I agree to all of the')} {" "}
|
||||
<a href="https://www.stirlingpdf.com/terms" target="_blank" rel="noopener noreferrer">
|
||||
{t('legal.terms', 'Terms and Conditions')}
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sign Up Button */}
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !email || !password || !confirmPassword || (showTerms && !agree)}
|
||||
className="auth-button"
|
||||
>
|
||||
{isSubmitting ? t('signup.creatingAccount') : t('signup.signUp')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface SignupFieldErrors {
|
||||
name?: string
|
||||
email?: string
|
||||
password?: string
|
||||
confirmPassword?: string
|
||||
}
|
||||
|
||||
export interface SignupValidationResult {
|
||||
isValid: boolean
|
||||
error: string | null
|
||||
fieldErrors?: SignupFieldErrors
|
||||
}
|
||||
|
||||
export const useSignupFormValidation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const validateSignupForm = (
|
||||
email: string,
|
||||
password: string,
|
||||
confirmPassword: string,
|
||||
name?: string
|
||||
): SignupValidationResult => {
|
||||
const fieldErrors: SignupFieldErrors = {};
|
||||
|
||||
// Validate name
|
||||
if (name !== undefined && name !== null && !name.trim()) {
|
||||
fieldErrors.name = t('signup.nameRequired', 'Name is required');
|
||||
}
|
||||
|
||||
// Validate email
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!email) {
|
||||
fieldErrors.email = t('signup.emailRequired', 'Email is required');
|
||||
} else if (!emailRegex.test(email)) {
|
||||
fieldErrors.email = t('signup.invalidEmail');
|
||||
}
|
||||
|
||||
// Validate password
|
||||
if (!password) {
|
||||
fieldErrors.password = t('signup.passwordRequired', 'Password is required');
|
||||
} else if (password.length < 6) {
|
||||
fieldErrors.password = t('signup.passwordTooShort');
|
||||
}
|
||||
|
||||
// Validate confirm password
|
||||
if (!confirmPassword) {
|
||||
fieldErrors.confirmPassword = t('signup.confirmPasswordRequired', 'Please confirm your password');
|
||||
} else if (password !== confirmPassword) {
|
||||
fieldErrors.confirmPassword = t('signup.passwordsDoNotMatch');
|
||||
}
|
||||
|
||||
const hasErrors = Object.keys(fieldErrors).length > 0;
|
||||
|
||||
return {
|
||||
isValid: !hasErrors,
|
||||
error: null, // Don't show generic error, field errors are more specific
|
||||
fieldErrors: hasErrors ? fieldErrors : undefined
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
validateSignupForm
|
||||
};
|
||||
};
|
||||
@@ -8,6 +8,35 @@ const apiClient = axios.create({
|
||||
responseType: 'json',
|
||||
});
|
||||
|
||||
// Helper function to get JWT token from localStorage
|
||||
function getJwtTokenFromStorage(): string | null {
|
||||
try {
|
||||
return localStorage.getItem('stirling_jwt');
|
||||
} catch (error) {
|
||||
console.error('[API Client] Failed to read JWT from localStorage:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Install request interceptor to add JWT token ----------
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// Get JWT token from localStorage
|
||||
const jwtToken = getJwtTokenFromStorage();
|
||||
|
||||
// If token exists and Authorization header is not already set, add it
|
||||
if (jwtToken && !config.headers.Authorization) {
|
||||
config.headers.Authorization = `Bearer ${jwtToken}`;
|
||||
console.debug('[API Client] Added JWT token from localStorage to Authorization header');
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// ---------- Install error interceptor ----------
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
|
||||
@@ -32,50 +32,6 @@
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes rainbowNebula {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
opacity: 0.22;
|
||||
}
|
||||
35% {
|
||||
transform: rotate(130deg) scale(1.1);
|
||||
opacity: 0.35;
|
||||
}
|
||||
70% {
|
||||
transform: rotate(260deg) scale(1.05);
|
||||
opacity: 0.28;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg) scale(1);
|
||||
opacity: 0.22;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rainbowSheen {
|
||||
0% { background-position: 0% 0%; }
|
||||
50% { background-position: 100% 100%; }
|
||||
100% { background-position: 0% 0%; }
|
||||
}
|
||||
|
||||
@keyframes rainbowSparkle {
|
||||
0%, 100% {
|
||||
opacity: 0.15;
|
||||
transform: translate3d(-4%, -2%, 0) scale(1);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.35;
|
||||
transform: translate3d(3%, -6%, 0) scale(1.05);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
transform: translate3d(6%, 4%, 0) scale(1.08);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.28;
|
||||
transform: translate3d(-2%, 6%, 0) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
/* Main rainbow theme class */
|
||||
.rainbowMode {
|
||||
background: linear-gradient(
|
||||
@@ -85,40 +41,7 @@
|
||||
background-size: 400% 400% !important;
|
||||
animation: rainbowBackground 3s ease infinite !important;
|
||||
color: white !important;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow-x: hidden;
|
||||
filter: saturate(1.25) brightness(1.05) contrast(1.05);
|
||||
}
|
||||
|
||||
.rainbowMode::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: -25vh;
|
||||
z-index: -2;
|
||||
background: conic-gradient(from 90deg at 50% 50%, #ff0088, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff, #ff0088);
|
||||
background-size: 120% 120%;
|
||||
filter: blur(160px);
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.3;
|
||||
animation: rainbowNebula 18s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rainbowMode::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 30%, rgba(255, 255, 255, 0.55) 0, rgba(255, 255, 255, 0) 45%),
|
||||
radial-gradient(circle at 80% 70%, rgba(255, 255, 255, 0.45) 0, rgba(255, 255, 255, 0) 40%),
|
||||
repeating-linear-gradient(120deg, rgba(255, 255, 255, 0.35) 0, rgba(255, 255, 255, 0.1) 8px, rgba(255, 255, 255, 0) 12px);
|
||||
background-size: 300px 300px, 240px 240px, 500% 500%;
|
||||
opacity: 0.22;
|
||||
mix-blend-mode: screen;
|
||||
animation: rainbowSparkle 14s ease-in-out infinite, rainbowSheen 24s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Rainbow components */
|
||||
@@ -132,8 +55,7 @@
|
||||
color: white !important;
|
||||
border: 2px solid !important;
|
||||
border-radius: 15px !important;
|
||||
box-shadow: 0 0 25px rgba(255, 255, 255, 0.35), 0 0 60px rgba(255, 0, 136, 0.25) !important;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 0 20px rgba(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
.rainbowButton {
|
||||
@@ -154,13 +76,6 @@
|
||||
transform: scale(1.05) !important;
|
||||
animation: rainbowBackground 1s ease infinite, rainbowBorder 0.5s linear infinite, rainbowPulse 0.5s ease infinite !important;
|
||||
box-shadow: 0 0 25px rgba(255, 255, 255, 0.6) !important;
|
||||
filter: drop-shadow(0 0 18px rgba(255, 0, 136, 0.45));
|
||||
}
|
||||
|
||||
.rainbowButton:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 25px rgba(255, 255, 255, 0.9), 0 0 20px rgba(255, 0, 136, 0.5) !important;
|
||||
transform: translateY(-2px) scale(1.04) !important;
|
||||
}
|
||||
|
||||
.rainbowInput {
|
||||
@@ -173,7 +88,6 @@
|
||||
border: 2px solid !important;
|
||||
color: white !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: inset 0 0 12px rgba(255, 255, 255, 0.2), 0 0 20px rgba(0, 255, 255, 0.35) !important;
|
||||
}
|
||||
|
||||
.rainbowInput::placeholder {
|
||||
@@ -196,20 +110,18 @@
|
||||
border: 2px solid !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 4px !important;
|
||||
box-shadow: 0 0 20px rgba(255, 255, 255, 0.4), 0 0 35px rgba(0, 136, 255, 0.35) !important;
|
||||
}
|
||||
|
||||
.rainbowPaper {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#00ffff, #0088ff, #8800ff, #ff0088
|
||||
#00ffff, #0088ff, #8800ff, #ff0088
|
||||
) !important;
|
||||
background-size: 100% 100% !important;
|
||||
animation: rainbowBackground 3.5s ease infinite, rainbowBorder 2s linear infinite !important;
|
||||
border: 2px solid !important;
|
||||
color: white !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 0 30px rgba(255, 255, 255, 0.35), 0 0 50px rgba(136, 0, 255, 0.35) !important;
|
||||
}
|
||||
|
||||
/* Easter egg notification */
|
||||
@@ -227,32 +139,11 @@
|
||||
font-size: 16px !important;
|
||||
z-index: 10000 !important;
|
||||
border: 2px solid white !important;
|
||||
box-shadow: 0 0 25px rgba(255, 255, 255, 0.9), 0 0 35px rgba(255, 136, 0, 0.45) !important;
|
||||
box-shadow: 0 0 20px rgba(255, 255, 255, 0.8) !important;
|
||||
user-select: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
:global(body.rainbow-mode-active) {
|
||||
background: radial-gradient(circle at top, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0) 55%),
|
||||
linear-gradient(135deg, rgba(255, 0, 136, 0.35), rgba(0, 136, 255, 0.2), rgba(0, 255, 136, 0.35));
|
||||
background-attachment: fixed;
|
||||
animation: rainbowBackground 18s ease infinite;
|
||||
}
|
||||
|
||||
:global(body.rainbow-mode-active)::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.3), transparent 55%),
|
||||
radial-gradient(circle at 90% 20%, rgba(255, 255, 255, 0.35), transparent 60%),
|
||||
radial-gradient(circle at 50% 90%, rgba(255, 255, 255, 0.25), transparent 55%);
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.2;
|
||||
animation: rainbowSparkle 26s ease-in-out infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Specific component overrides */
|
||||
.rainbowMode [data-mantine-color-scheme] {
|
||||
background: linear-gradient(
|
||||
@@ -275,7 +166,6 @@
|
||||
animation: rainbowBackground 2s ease infinite, rainbowBorder 1.5s linear infinite !important;
|
||||
color: white !important;
|
||||
font-weight: bold !important;
|
||||
letter-spacing: 0.4px !important;
|
||||
}
|
||||
|
||||
/* Make all inputs rainbow in rainbow mode */
|
||||
@@ -291,7 +181,6 @@
|
||||
border: 2px solid !important;
|
||||
animation: rainbowBackground 2.5s ease infinite, rainbowBorder 1.5s linear infinite !important;
|
||||
color: white !important;
|
||||
box-shadow: inset 0 0 12px rgba(255, 255, 255, 0.15), 0 0 20px rgba(136, 0, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
/* Rainbow text class */
|
||||
@@ -311,29 +200,3 @@
|
||||
animation: rainbowText 3s linear infinite !important;
|
||||
font-weight: bold !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rainbowMode,
|
||||
.rainbowCard,
|
||||
.rainbowButton,
|
||||
.rainbowInput,
|
||||
.rainbowSegmentedControl,
|
||||
.rainbowPaper,
|
||||
.rainbowNotification,
|
||||
.rainbowMode button,
|
||||
.rainbowMode input,
|
||||
.rainbowMode select,
|
||||
.rainbowMode textarea,
|
||||
.rainbowText,
|
||||
:global(body.rainbow-mode-active) {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.rainbowMode::before,
|
||||
.rainbowMode::after,
|
||||
:global(body.rainbow-mode-active)::before {
|
||||
animation: none !important;
|
||||
opacity: 0.12 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +262,27 @@
|
||||
--modal-content-bg: #ffffff;
|
||||
--modal-header-border: rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* Auth page colors (light mode only - auth pages force light mode) */
|
||||
--auth-bg-color-light-only: #f3f4f6;
|
||||
--auth-card-bg: #ffffff;
|
||||
--auth-card-bg-light-only: #ffffff;
|
||||
--auth-label-text-light-only: #374151;
|
||||
--auth-input-border-light-only: #d1d5db;
|
||||
--auth-input-bg-light-only: #ffffff;
|
||||
--auth-input-text-light-only: #111827;
|
||||
--auth-border-focus-light-only: #3b82f6;
|
||||
--auth-focus-ring-light-only: rgba(59, 130, 246, 0.1);
|
||||
--auth-button-bg-light-only: #AF3434;
|
||||
--auth-button-text-light-only: #ffffff;
|
||||
--auth-magic-button-bg-light-only: #e5e7eb;
|
||||
--auth-magic-button-text-light-only: #374151;
|
||||
--auth-text-primary-light-only: #111827;
|
||||
--auth-text-secondary-light-only: #6b7280;
|
||||
--text-divider-rule-rgb-light: 229, 231, 235;
|
||||
--text-divider-label-rgb-light: 156, 163, 175;
|
||||
--tool-subcategory-rule-color-light: #e5e7eb;
|
||||
--tool-subcategory-text-color-light: #9ca3af;
|
||||
|
||||
/* PDF Report Colors (always light) */
|
||||
--pdf-light-header-bg: 239 246 255;
|
||||
--pdf-light-accent: 59 130 246;
|
||||
@@ -480,7 +501,7 @@
|
||||
/* Tool panel search bar background colors (dark mode) */
|
||||
--tool-panel-search-bg: #1F2329;
|
||||
--tool-panel-search-border-bottom: #4B525A;
|
||||
|
||||
|
||||
--information-text-bg: #292e34;
|
||||
--information-text-color: #ececec;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'path';
|
||||
import ts from 'typescript';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '../../../')
|
||||
const REPO_ROOT = path.join(__dirname, '../../../');
|
||||
const SRC_ROOT = path.join(__dirname, '..');
|
||||
const EN_GB_FILE = path.join(__dirname, '../../public/locales/en-GB/translation.json');
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createToolFlow } from '../components/tools/shared/createToolFlow';
|
||||
import { BaseToolProps, ToolComponent } from '../types/tool';
|
||||
import { useBaseTool } from '../hooks/tools/shared/useBaseTool';
|
||||
|
||||