mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 21:30:14 +03:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24cabb7070 | ||
|
|
a07f2cbe05 | ||
|
|
03a311e78d | ||
|
|
2de89fb173 | ||
|
|
db815802f7 |
@@ -3,9 +3,9 @@ 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=DEBUG
|
||||
logging.level.org.springframework.security=DEBUG
|
||||
#logging.level.org.opensaml=DEBUG
|
||||
#logging.level.stirling.software.SPDF.config.security: DEBUG
|
||||
logging.level.stirling.software: DEBUG
|
||||
logging.level.com.zaxxer.hikari=WARN
|
||||
spring.jpa.open-in-view=false
|
||||
server.forward-headers-strategy=NATIVE
|
||||
@@ -55,4 +55,14 @@ posthog.host=https://eu.i.posthog.com
|
||||
spring.main.allow-bean-definition-overriding=true
|
||||
|
||||
# Set up a consistent temporary directory location
|
||||
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
|
||||
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
|
||||
|
||||
|
||||
#spring.security.oauth2.resourceserver.jwt.issuer-uri=https://nrlkjfznsavsbmweiyqu.supabase.co/auth/v1
|
||||
|
||||
spring.security.oauth2.resourceserver.jwk-set-uri=https://nrlkjfznsavsbmweiyqu.supabase.co/auth/v1/.well-known/jwks.json
|
||||
spring.security.oauth2.resourceserver.audience=authenticated
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security: DEBUG
|
||||
your.project.pkg.security: DEBUG
|
||||
@@ -37,6 +37,8 @@ dependencies {
|
||||
api "org.springframework.security:spring-security-core:$springSecuritySamlVersion"
|
||||
api "org.springframework.security:spring-security-web:$springSecuritySamlVersion"
|
||||
api "org.springframework.security:spring-security-config:$springSecuritySamlVersion"
|
||||
api("org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.4")
|
||||
api "org.springframework.security:spring-security-oauth2-jose:$springSecuritySamlVersion"
|
||||
api "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion"
|
||||
api 'org.springframework.boot:spring-boot-starter-jetty'
|
||||
api 'org.springframework.boot:spring-boot-starter-security'
|
||||
|
||||
-11
@@ -7,14 +7,3 @@ import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RateLimitResetScheduler {
|
||||
|
||||
private final IPRateLimitingFilter rateLimitingFilter;
|
||||
|
||||
@Scheduled(cron = "0 0 0 * * MON") // At 00:00 every Monday TODO: configurable
|
||||
public void resetRateLimit() {
|
||||
rateLimitingFilter.resetRequestCounts();
|
||||
}
|
||||
}
|
||||
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
package stirling.software.proprietary.security.configuration;
|
||||
|
||||
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.authentication.event.AbstractAuthenticationEvent;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
|
||||
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
|
||||
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Security configuration for Supabase-issued JWTs only.
|
||||
*
|
||||
* Requires:
|
||||
*
|
||||
* spring:
|
||||
* security:
|
||||
* oauth2:
|
||||
* resourceserver:
|
||||
* jwt:
|
||||
* issuer-uri: https://<project-id>.supabase.co/auth/v1
|
||||
*
|
||||
* Optional logging (application.yml):
|
||||
* logging:
|
||||
* level:
|
||||
* org.springframework.security: DEBUG
|
||||
* your.project.pkg.security: DEBUG
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);
|
||||
|
||||
/** Your Supabase project ref, e.g. abcd1234efgh5678ijkl */
|
||||
@Value("${app.supabase.project-ref:nrlkjfznsavsbmweiyqu}")
|
||||
private String projectRef;
|
||||
|
||||
/** Optional audience to enforce (leave empty to skip) */
|
||||
@Value("${app.jwt.expected-aud:}")
|
||||
private String expectedAud;
|
||||
|
||||
/** Clock skew in seconds for exp validation */
|
||||
@Value("${app.jwt.clock-skew-seconds:120}")
|
||||
private long clockSkewSeconds;
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(Customizer.withDefaults())
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// allow CORS preflight only
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
// public endpoints
|
||||
.requestMatchers(
|
||||
new AntPathRequestMatcher("/actuator/health"),
|
||||
new AntPathRequestMatcher("/public/**"),
|
||||
new AntPathRequestMatcher("/images/**"),
|
||||
new AntPathRequestMatcher("/css/**"),
|
||||
new AntPathRequestMatcher("/js/**")
|
||||
).permitAll()
|
||||
// everything else requires auth
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(new VerboseAuthLoggingFilter(), BearerTokenAuthenticationFilter.class)
|
||||
.exceptionHandling(ex -> ex
|
||||
.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
|
||||
.accessDeniedHandler(new BearerTokenAccessDeniedHandler())
|
||||
)
|
||||
.oauth2ResourceServer(oauth -> oauth
|
||||
.jwt(jwt -> jwt
|
||||
.decoder(jwtDecoder)
|
||||
.jwtAuthenticationConverter(this::toAuthentication)
|
||||
)
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/** CORS config so browser can send Authorization on real requests. */
|
||||
@Bean
|
||||
CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
cfg.setAllowedOrigins(List.of(
|
||||
"http://localhost:3000", // dev
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080",
|
||||
"https://your-frontend.example"// prod
|
||||
));
|
||||
cfg.setAllowedMethods(List.of("GET","POST","PUT","PATCH","DELETE","OPTIONS"));
|
||||
cfg.setAllowedHeaders(List.of("Authorization","Content-Type","X-Requested-With","Accept","Origin"));
|
||||
cfg.setExposedHeaders(List.of("WWW-Authenticate"));
|
||||
cfg.setAllowCredentials(true);
|
||||
cfg.setMaxAge(3600L);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
}
|
||||
|
||||
/** JWKS-based decoder + custom validator (issuer/exp/aud). */
|
||||
@Bean
|
||||
JwtDecoder jwtDecoder() {
|
||||
String issuer = "https://" + projectRef + ".supabase.co/auth/v1"; // no trailing slash
|
||||
String jwks = issuer + "/.well-known/jwks.json";
|
||||
|
||||
log.info("Configuring JWT decoder with JWKS: {}", jwks);
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build();
|
||||
decoder.setJwtValidator(new CompositeValidator(issuer, expectedAud, Duration.ofSeconds(clockSkewSeconds)));
|
||||
|
||||
if (expectedAud == null || expectedAud.isBlank()) {
|
||||
log.info("JWT validation: enforcing issuer='{}' and exp (skew {}s)", issuer, clockSkewSeconds);
|
||||
} else {
|
||||
log.info("JWT validation: enforcing issuer='{}', audience='{}', and exp (skew {}s)",
|
||||
issuer, expectedAud, clockSkewSeconds);
|
||||
}
|
||||
return decoder;
|
||||
}
|
||||
|
||||
/** Map claims -> authorities. DEBUG: hotmail.com => ROLE_ADMIN. */
|
||||
private AbstractAuthenticationToken toAuthentication(Jwt jwt) {
|
||||
List<GrantedAuthority> authorities = new ArrayList<>();
|
||||
|
||||
// Supabase default role -> ROLE_authenticated (optional but handy)
|
||||
String supabaseRole = jwt.getClaimAsString("role"); // often "authenticated"
|
||||
if (supabaseRole != null && !supabaseRole.isBlank()) {
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_" + supabaseRole));
|
||||
}
|
||||
|
||||
// Your custom app_role (if you add it via Access Token Hook) -> ROLE_*
|
||||
String appRole = jwt.getClaimAsString("app_role");
|
||||
if (appRole != null && !appRole.isBlank()) {
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_" + appRole.toUpperCase()));
|
||||
}
|
||||
|
||||
// DEBUG RULE: email domain → admin
|
||||
String email = jwt.getClaimAsString("email");
|
||||
if (email != null && email.toLowerCase(Locale.ROOT).endsWith("@hotmail.com")) {
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
|
||||
} else {
|
||||
// Give a basic user role for convenience while debugging (optional)
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
}
|
||||
|
||||
// Optional permissions array -> PERM_*
|
||||
List<String> perms = jwt.getClaimAsStringList("permissions");
|
||||
if (perms != null) {
|
||||
perms.stream()
|
||||
.filter(p -> p != null && !p.isBlank())
|
||||
.map(p -> new SimpleGrantedAuthority("PERM_" + p))
|
||||
.forEach(authorities::add);
|
||||
}
|
||||
|
||||
String principalName = (email != null && !email.isBlank()) ? email : jwt.getSubject();
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("JWT accepted: sub='{}', email='{}', supabase.role='{}', app_role='{}', permissions={}",
|
||||
jwt.getSubject(), email, supabaseRole, appRole, perms);
|
||||
log.debug("Granted authorities: {}", authorities.stream()
|
||||
.map(GrantedAuthority::getAuthority).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
return new JwtAuthenticationToken(jwt, authorities, principalName);
|
||||
}
|
||||
|
||||
/** Logs authentication lifecycle events (success/failure). */
|
||||
@Bean
|
||||
ApplicationListener<AbstractAuthenticationEvent> authenticationEventsLogger() {
|
||||
return event -> {
|
||||
try {
|
||||
if (event.getSource() instanceof AbstractAuthenticationToken auth) {
|
||||
String type = event.getClass().getSimpleName();
|
||||
String name = auth.getName();
|
||||
String authorities = auth.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
|
||||
log.debug("[AuthEvent] {} principal='{}' authorities='{}' details={}",
|
||||
type, name, authorities, auth.getDetails());
|
||||
} else {
|
||||
log.debug("[AuthEvent] {} source={}", event.getClass().getSimpleName(), event.getSource());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to log authentication event", e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Super-chatty per-request logger around bearer processing; never logs raw token. */
|
||||
static class VerboseAuthLoggingFilter extends OncePerRequestFilter {
|
||||
private static final Logger flog = LoggerFactory.getLogger(VerboseAuthLoggingFilter.class);
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
boolean hasBearer = authHeader != null && authHeader.startsWith("Bearer ");
|
||||
if (flog.isDebugEnabled()) {
|
||||
flog.debug("[REQ] {} {} AuthorizationHeaderPresent={} (token hidden)",
|
||||
request.getMethod(), request.getRequestURI(), hasBearer);
|
||||
}
|
||||
|
||||
try {
|
||||
filterChain.doFilter(request, response);
|
||||
} catch (InvalidBearerTokenException ibte) {
|
||||
flog.warn("[AUTH] Invalid bearer token: {}", ibte.getMessage());
|
||||
throw ibte;
|
||||
} catch (Exception ex) {
|
||||
flog.error("[AUTH] Unexpected auth error: {}", ex.toString(), ex);
|
||||
throw ex;
|
||||
}
|
||||
|
||||
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth instanceof JwtAuthenticationToken jwtAuth) {
|
||||
String authorities = jwtAuth.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
|
||||
flog.debug("[AUTH] OK principal='{}' authorities='{}'", jwtAuth.getName(), authorities);
|
||||
} else {
|
||||
flog.debug("[AUTH] No authentication established");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validator: issuer == expected, not expired (with skew), optional audience. */
|
||||
static final class CompositeValidator implements OAuth2TokenValidator<Jwt> {
|
||||
private final String expectedIssuer; // not null
|
||||
private final String expectedAudienceOrNull; // may be null/blank
|
||||
private final Duration skew;
|
||||
|
||||
CompositeValidator(String expectedIssuer, String expectedAudienceOrNull, Duration skew) {
|
||||
this.expectedIssuer = Objects.requireNonNull(expectedIssuer);
|
||||
this.expectedAudienceOrNull = (expectedAudienceOrNull != null && !expectedAudienceOrNull.isBlank())
|
||||
? expectedAudienceOrNull : null;
|
||||
this.skew = Objects.requireNonNull(skew);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt token) {
|
||||
List<OAuth2Error> errors = new ArrayList<>();
|
||||
|
||||
String iss = token.getIssuer() != null ? token.getIssuer().toString() : null;
|
||||
if (iss == null || !iss.equals(expectedIssuer)) {
|
||||
errors.add(new OAuth2Error("invalid_token", "Invalid issuer: " + iss, null));
|
||||
}
|
||||
|
||||
Instant exp = token.getExpiresAt();
|
||||
if (exp == null) {
|
||||
errors.add(new OAuth2Error("invalid_token", "Missing exp claim", null));
|
||||
} else if (exp.isBefore(Instant.now().minus(skew))) {
|
||||
errors.add(new OAuth2Error("invalid_token", "Token expired at " + exp, null));
|
||||
}
|
||||
|
||||
if (expectedAudienceOrNull != null) {
|
||||
List<String> aud = token.getAudience();
|
||||
if (aud == null || !aud.contains(expectedAudienceOrNull)) {
|
||||
errors.add(new OAuth2Error("invalid_token", "Missing/invalid audience: " + expectedAudienceOrNull, null));
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
errors.forEach(e -> log.warn("JWT validation error: {} - {}", e.getErrorCode(), e.getDescription()));
|
||||
return OAuth2TokenValidatorResult.failure(errors);
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
}
|
||||
}
|
||||
-326
@@ -1,326 +0,0 @@
|
||||
package stirling.software.proprietary.security.configuration;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.security.authentication.ProviderManager;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||
import org.springframework.security.web.savedrequest.NullRequestCache;
|
||||
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
|
||||
import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler;
|
||||
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
|
||||
import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl;
|
||||
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
|
||||
import stirling.software.proprietary.security.filter.FirstLoginFilter;
|
||||
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
|
||||
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
|
||||
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter;
|
||||
import stirling.software.proprietary.security.service.CustomOAuth2UserService;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
@DependsOn("runningProOrHigher")
|
||||
public class SecurityConfiguration {
|
||||
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final UserService userService;
|
||||
private final boolean loginEnabledValue;
|
||||
private final boolean runningProOrHigher;
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final AppConfig appConfig;
|
||||
private final UserAuthenticationFilter userAuthenticationFilter;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final FirstLoginFilter firstLoginFilter;
|
||||
private final SessionPersistentRegistry sessionRegistry;
|
||||
private final PersistentLoginRepository persistentLoginRepository;
|
||||
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
|
||||
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
|
||||
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
|
||||
|
||||
public SecurityConfiguration(
|
||||
PersistentLoginRepository persistentLoginRepository,
|
||||
CustomUserDetailsService userDetailsService,
|
||||
@Lazy UserService userService,
|
||||
@Qualifier("loginEnabled") boolean loginEnabledValue,
|
||||
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
|
||||
AppConfig appConfig,
|
||||
ApplicationProperties applicationProperties,
|
||||
UserAuthenticationFilter userAuthenticationFilter,
|
||||
LoginAttemptService loginAttemptService,
|
||||
FirstLoginFilter firstLoginFilter,
|
||||
SessionPersistentRegistry sessionRegistry,
|
||||
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
|
||||
@Autowired(required = false)
|
||||
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
|
||||
@Autowired(required = false)
|
||||
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver) {
|
||||
this.userDetailsService = userDetailsService;
|
||||
this.userService = userService;
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
this.runningProOrHigher = runningProOrHigher;
|
||||
this.appConfig = appConfig;
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.userAuthenticationFilter = userAuthenticationFilter;
|
||||
this.loginAttemptService = loginAttemptService;
|
||||
this.firstLoginFilter = firstLoginFilter;
|
||||
this.sessionRegistry = sessionRegistry;
|
||||
this.persistentLoginRepository = persistentLoginRepository;
|
||||
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
|
||||
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
|
||||
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
if (applicationProperties.getSecurity().getCsrfDisabled() || !loginEnabledValue) {
|
||||
http.csrf(csrf -> csrf.disable());
|
||||
}
|
||||
|
||||
if (loginEnabledValue) {
|
||||
http.addFilterBefore(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
if (!applicationProperties.getSecurity().getCsrfDisabled()) {
|
||||
CookieCsrfTokenRepository cookieRepo =
|
||||
CookieCsrfTokenRepository.withHttpOnlyFalse();
|
||||
CsrfTokenRequestAttributeHandler requestHandler =
|
||||
new CsrfTokenRequestAttributeHandler();
|
||||
requestHandler.setCsrfRequestAttributeName(null);
|
||||
http.csrf(
|
||||
csrf ->
|
||||
csrf.ignoringRequestMatchers(
|
||||
request -> {
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
// If there's no API key, don't ignore CSRF
|
||||
// (return false)
|
||||
if (apiKey == null || apiKey.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// Validate API key using existing UserService
|
||||
try {
|
||||
Optional<User> user =
|
||||
userService.getUserByApiKey(apiKey);
|
||||
// If API key is valid, ignore CSRF (return
|
||||
// true)
|
||||
// If API key is invalid, don't ignore CSRF
|
||||
// (return false)
|
||||
return user.isPresent();
|
||||
} catch (Exception e) {
|
||||
// If there's any error validating the API
|
||||
// key, don't ignore CSRF
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.csrfTokenRepository(cookieRepo)
|
||||
.csrfTokenRequestHandler(requestHandler));
|
||||
}
|
||||
http.addFilterBefore(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
http.sessionManagement(
|
||||
sessionManagement ->
|
||||
sessionManagement
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
.maximumSessions(10)
|
||||
.maxSessionsPreventsLogin(false)
|
||||
.sessionRegistry(sessionRegistry)
|
||||
.expiredUrl("/login?logout=true"));
|
||||
http.authenticationProvider(daoAuthenticationProvider());
|
||||
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
|
||||
http.logout(
|
||||
logout ->
|
||||
logout.logoutRequestMatcher(
|
||||
PathPatternRequestMatcher.withDefaults()
|
||||
.matcher("/logout"))
|
||||
.logoutSuccessHandler(
|
||||
new CustomLogoutSuccessHandler(
|
||||
applicationProperties, appConfig))
|
||||
.clearAuthentication(true)
|
||||
.invalidateHttpSession(true)
|
||||
.deleteCookies("JSESSIONID", "remember-me"));
|
||||
http.rememberMe(
|
||||
rememberMeConfigurer -> // Use the configurator directly
|
||||
rememberMeConfigurer
|
||||
.tokenRepository(persistentTokenRepository())
|
||||
.tokenValiditySeconds( // 14 days
|
||||
14 * 24 * 60 * 60)
|
||||
.userDetailsService( // Your existing UserDetailsService
|
||||
userDetailsService)
|
||||
.useSecureCookie( // Enable secure cookie
|
||||
true)
|
||||
.rememberMeParameter( // Form parameter name
|
||||
"remember-me")
|
||||
.rememberMeCookieName( // Cookie name
|
||||
"remember-me")
|
||||
.alwaysRemember(false));
|
||||
http.authorizeHttpRequests(
|
||||
authz ->
|
||||
authz.requestMatchers(
|
||||
req -> {
|
||||
String uri = req.getRequestURI();
|
||||
String contextPath = req.getContextPath();
|
||||
// Remove the context path from the URI
|
||||
String trimmedUri =
|
||||
uri.startsWith(contextPath)
|
||||
? uri.substring(
|
||||
contextPath.length())
|
||||
: uri;
|
||||
return trimmedUri.startsWith("/login")
|
||||
|| trimmedUri.startsWith("/oauth")
|
||||
|| trimmedUri.startsWith("/saml2")
|
||||
|| trimmedUri.endsWith(".svg")
|
||||
|| trimmedUri.startsWith("/register")
|
||||
|| trimmedUri.startsWith("/error")
|
||||
|| trimmedUri.startsWith("/images/")
|
||||
|| trimmedUri.startsWith("/public/")
|
||||
|| trimmedUri.startsWith("/css/")
|
||||
|| trimmedUri.startsWith("/fonts/")
|
||||
|| trimmedUri.startsWith("/js/")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/info/status");
|
||||
})
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
.authenticated());
|
||||
// Handle User/Password Logins
|
||||
if (applicationProperties.getSecurity().isUserPass()) {
|
||||
http.formLogin(
|
||||
formLogin ->
|
||||
formLogin
|
||||
.loginPage("/login")
|
||||
.successHandler(
|
||||
new CustomAuthenticationSuccessHandler(
|
||||
loginAttemptService, userService))
|
||||
.failureHandler(
|
||||
new CustomAuthenticationFailureHandler(
|
||||
loginAttemptService, userService))
|
||||
.defaultSuccessUrl("/")
|
||||
.permitAll());
|
||||
}
|
||||
// Handle OAUTH2 Logins
|
||||
if (applicationProperties.getSecurity().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,
|
||||
applicationProperties,
|
||||
userService))
|
||||
.failureHandler(
|
||||
new CustomOAuth2AuthenticationFailureHandler())
|
||||
. // Add existing Authorities from the database
|
||||
userInfoEndpoint(
|
||||
userInfoEndpoint ->
|
||||
userInfoEndpoint
|
||||
.oidcUserService(
|
||||
new CustomOAuth2UserService(
|
||||
applicationProperties,
|
||||
userService,
|
||||
loginAttemptService))
|
||||
.userAuthoritiesMapper(
|
||||
oAuth2userAuthoritiesMapper))
|
||||
.permitAll());
|
||||
}
|
||||
// Handle SAML
|
||||
if (applicationProperties.getSecurity().isSaml2Active() && runningProOrHigher) {
|
||||
// Configure the authentication provider
|
||||
OpenSaml4AuthenticationProvider authenticationProvider =
|
||||
new OpenSaml4AuthenticationProvider();
|
||||
authenticationProvider.setResponseAuthenticationConverter(
|
||||
new CustomSaml2ResponseAuthenticationConverter(userService));
|
||||
http.authenticationProvider(authenticationProvider)
|
||||
.saml2Login(
|
||||
saml2 -> {
|
||||
try {
|
||||
saml2.loginPage("/saml2")
|
||||
.relyingPartyRegistrationRepository(
|
||||
saml2RelyingPartyRegistrations)
|
||||
.authenticationManager(
|
||||
new ProviderManager(authenticationProvider))
|
||||
.successHandler(
|
||||
new CustomSaml2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
applicationProperties,
|
||||
userService))
|
||||
.failureHandler(
|
||||
new CustomSaml2AuthenticationFailureHandler())
|
||||
.authenticationRequestResolver(
|
||||
saml2AuthenticationRequestResolver);
|
||||
} catch (Exception e) {
|
||||
log.error("Error configuring SAML 2 login", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log.debug("Login is not enabled.");
|
||||
http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll());
|
||||
}
|
||||
return http.build();
|
||||
}
|
||||
|
||||
public DaoAuthenticationProvider daoAuthenticationProvider() {
|
||||
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);
|
||||
provider.setPasswordEncoder(passwordEncoder());
|
||||
return provider;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IPRateLimitingFilter rateLimitingFilter() {
|
||||
// Example limit TODO add config level
|
||||
int maxRequestsPerIp = 1000000;
|
||||
return new IPRateLimitingFilter(maxRequestsPerIp, maxRequestsPerIp);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PersistentTokenRepository persistentTokenRepository() {
|
||||
return new JPATokenRepositoryImpl(persistentLoginRepository);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -43,7 +43,7 @@ import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequ
|
||||
@Tag(name = "Admin Settings", description = "Admin-only Settings Management APIs")
|
||||
@RequestMapping("/api/v1/admin/settings")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Slf4j
|
||||
public class AdminSettingsController {
|
||||
|
||||
|
||||
+3
-9
@@ -105,9 +105,7 @@ public class UserController {
|
||||
if (user.getUsername().equals(newUsername)) {
|
||||
return new RedirectView("/account?messageType=usernameExists", true);
|
||||
}
|
||||
if (!userService.isPasswordCorrect(user, currentPassword)) {
|
||||
return new RedirectView("/account?messageType=incorrectPassword", true);
|
||||
}
|
||||
|
||||
if (!user.getUsername().equals(newUsername) && userService.usernameExists(newUsername)) {
|
||||
return new RedirectView("/account?messageType=usernameExists", true);
|
||||
}
|
||||
@@ -141,9 +139,7 @@ public class UserController {
|
||||
return new RedirectView("/change-creds?messageType=userNotFound", true);
|
||||
}
|
||||
User user = userOpt.get();
|
||||
if (!userService.isPasswordCorrect(user, currentPassword)) {
|
||||
return new RedirectView("/change-creds?messageType=incorrectPassword", true);
|
||||
}
|
||||
|
||||
userService.changePassword(user, newPassword);
|
||||
userService.changeFirstUse(user, false);
|
||||
// Logout using Spring's utility
|
||||
@@ -169,9 +165,7 @@ public class UserController {
|
||||
return new RedirectView("/account?messageType=userNotFound", true);
|
||||
}
|
||||
User user = userOpt.get();
|
||||
if (!userService.isPasswordCorrect(user, currentPassword)) {
|
||||
return new RedirectView("/account?messageType=incorrectPassword", true);
|
||||
}
|
||||
|
||||
userService.changePassword(user, newPassword);
|
||||
// Logout using Spring's utility
|
||||
new SecurityContextLogoutHandler().logout(request, response, null);
|
||||
|
||||
+3
-7
@@ -51,7 +51,6 @@ public class UserService implements UserServiceInterface {
|
||||
private final TeamRepository teamRepository;
|
||||
private final AuthorityRepository authorityRepository;
|
||||
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
private final MessageSource messageSource;
|
||||
|
||||
@@ -344,7 +343,7 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
public void changePassword(User user, String newPassword)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
user.setPassword(passwordEncoder.encode(newPassword));
|
||||
//user.setPassword(passwordEncoder.encode(newPassword));
|
||||
userRepository.save(user);
|
||||
databaseService.exportDatabase();
|
||||
}
|
||||
@@ -381,10 +380,7 @@ public class UserService implements UserServiceInterface {
|
||||
databaseService.exportDatabase();
|
||||
}
|
||||
|
||||
public boolean isPasswordCorrect(User user, String currentPassword) {
|
||||
return passwordEncoder.matches(currentPassword, user.getPassword());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resolves a team based on the provided information, with consistent error handling.
|
||||
*
|
||||
@@ -456,7 +452,7 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
// Set password if provided
|
||||
if (password != null && !password.isEmpty()) {
|
||||
user.setPassword(passwordEncoder.encode(password));
|
||||
// user.setPassword(passwordEncoder.encode(password));
|
||||
}
|
||||
|
||||
// Set authentication type
|
||||
|
||||
Generated
+113
-4
@@ -16,6 +16,7 @@
|
||||
"@mantine/hooks": "^8.0.1",
|
||||
"@mui/icons-material": "^7.1.0",
|
||||
"@mui/material": "^7.1.0",
|
||||
"@supabase/supabase-js": "^2.55.0",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
@@ -1918,6 +1919,102 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz",
|
||||
"integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz",
|
||||
"integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/node-fetch": {
|
||||
"version": "2.6.15",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
|
||||
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/node-fetch/node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/node-fetch/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@supabase/node-fetch/node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "1.19.4",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz",
|
||||
"integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.1.tgz",
|
||||
"integrity": "sha512-edRFa2IrQw50kNntvUyS38hsL7t2d/psah6om6aNTLLcWem0R6bOUq7sk7DsGeSlNfuwEwWn57FdYSva6VddYw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.13",
|
||||
"@types/phoenix": "^1.6.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ws": "^8.18.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.11.0.tgz",
|
||||
"integrity": "sha512-Y+kx/wDgd4oasAgoAq0bsbQojwQ+ejIif8uczZ9qufRHWFLMU5cODT+ApHsSrDufqUcVKt+eyxtOXSkeh2v9ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.55.0.tgz",
|
||||
"integrity": "sha512-Y1uV4nEMjQV1x83DGn7+Z9LOisVVRlY1geSARrUHbXWgbyKLZ6/08dvc0Us1r6AJ4tcKpwpCZWG9yDQYo1JgHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.71.1",
|
||||
"@supabase/functions-js": "2.4.5",
|
||||
"@supabase/node-fetch": "2.6.15",
|
||||
"@supabase/postgrest-js": "1.19.4",
|
||||
"@supabase/realtime-js": "2.15.1",
|
||||
"@supabase/storage-js": "^2.10.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.8.tgz",
|
||||
@@ -2389,7 +2486,6 @@
|
||||
"version": "24.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.0.tgz",
|
||||
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.10.0"
|
||||
}
|
||||
@@ -2400,6 +2496,12 @@
|
||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
|
||||
@@ -2434,6 +2536,15 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz",
|
||||
@@ -7417,8 +7528,7 @@
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||
"dev": true
|
||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
@@ -8899,7 +9009,6 @@
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@mantine/hooks": "^8.0.1",
|
||||
"@mui/icons-material": "^7.1.0",
|
||||
"@mui/material": "^7.1.0",
|
||||
"@supabase/supabase-js": "^2.55.0",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
|
||||
+21
-5
@@ -1,8 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
|
||||
import { FileContextProvider } from './contexts/FileContext';
|
||||
import { FilesModalProvider } from './contexts/FilesModalContext';
|
||||
import { AuthProvider } from './lib/useSession';
|
||||
import HomePage from './pages/HomePage';
|
||||
import LoginCompact from './routes/LoginCompact';
|
||||
import Signup from './routes/Signup';
|
||||
import AuthCallback from './routes/AuthCallback';
|
||||
import AuthDebug from './routes/AuthDebug';
|
||||
|
||||
// Import global styles
|
||||
import './styles/tailwind.css';
|
||||
@@ -11,11 +17,21 @@ import './index.css';
|
||||
export default function App() {
|
||||
return (
|
||||
<RainbowThemeProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<FilesModalProvider>
|
||||
<HomePage />
|
||||
</FilesModalProvider>
|
||||
</FileContextProvider>
|
||||
<AuthProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<FilesModalProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<LoginCompact />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/debug" element={<AuthDebug />} />
|
||||
{/* Catch-all route - redirects unknown paths to home */}
|
||||
<Route path="*" element={<HomePage />} />
|
||||
</Routes>
|
||||
</FilesModalProvider>
|
||||
</FileContextProvider>
|
||||
</AuthProvider>
|
||||
</RainbowThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './lib/useSession'
|
||||
import { supabase } from './lib/supabase'
|
||||
import RequireAuth from './components/auth/RequireAuth'
|
||||
import Login from './routes/Login'
|
||||
import AuthCallback from './routes/AuthCallback'
|
||||
import AuthDebug from './routes/AuthDebug'
|
||||
|
||||
// Example protected component
|
||||
function ProtectedDashboard() {
|
||||
const { session, user, signOut } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-600">Welcome back!</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
|
||||
<h3 className="font-medium text-green-900">Authentication Successful!</h3>
|
||||
<p className="text-green-700 text-sm">You are signed in as {user?.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-gray-50 rounded">
|
||||
<div className="text-sm font-medium text-gray-700">User ID</div>
|
||||
<div className="font-mono text-gray-900 break-all">{user?.id}</div>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 rounded">
|
||||
<div className="text-sm font-medium text-gray-700">Email</div>
|
||||
<div className="text-gray-900">{user?.email}</div>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 rounded">
|
||||
<div className="text-sm font-medium text-gray-700">Provider</div>
|
||||
<div className="text-gray-900">{user?.app_metadata?.provider}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm font-medium text-gray-700 hover:text-gray-900">
|
||||
Full Session Data
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto max-h-48">
|
||||
{JSON.stringify(session, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Example home page
|
||||
function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4 text-center">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Stirling PDF - Authentication Demo
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
This is a demo of the Supabase authentication integration
|
||||
</p>
|
||||
|
||||
<div className="space-x-4">
|
||||
<a
|
||||
href="/login"
|
||||
className="inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Go to Login
|
||||
</a>
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="inline-block px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
Protected Dashboard
|
||||
</a>
|
||||
<a
|
||||
href="/debug"
|
||||
className="inline-block px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Debug Panel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Router configuration
|
||||
const router = createBrowserRouter([
|
||||
// Public routes
|
||||
{ path: '/', element: <HomePage /> },
|
||||
{ path: '/login', element: <Login /> },
|
||||
{ path: '/auth/callback', element: <AuthCallback /> },
|
||||
{ path: '/debug', element: <AuthDebug /> },
|
||||
|
||||
// Protected routes
|
||||
{
|
||||
path: '/dashboard',
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<ProtectedDashboard />
|
||||
</RequireAuth>
|
||||
)
|
||||
},
|
||||
])
|
||||
|
||||
// Main App component with auth provider
|
||||
export default function AuthExample() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Additional utility functions for easy integration
|
||||
export const authUtils = {
|
||||
// Sign in with GitHub (can be called from anywhere)
|
||||
signInWithGitHub: async (nextPath = '/') => {
|
||||
const redirectTo = `${window.location.origin}/auth/callback?next=${encodeURIComponent(nextPath)}`
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'github',
|
||||
options: { redirectTo }
|
||||
})
|
||||
if (error) {
|
||||
console.error('Sign in error:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Sign out (can be called from anywhere)
|
||||
signOut: async () => {
|
||||
const { error } = await supabase.auth.signOut()
|
||||
if (error) {
|
||||
console.error('Sign out error:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Get current session
|
||||
getCurrentSession: async () => {
|
||||
const { data, error } = await supabase.auth.getSession()
|
||||
return { session: data.session, error }
|
||||
},
|
||||
|
||||
// Check if user is authenticated
|
||||
isAuthenticated: async () => {
|
||||
const { session } = await authUtils.getCurrentSession()
|
||||
return !!session
|
||||
}
|
||||
}
|
||||
|
||||
// Import this in your main App.tsx or wherever you want to add auth
|
||||
// import AuthExample from './AuthExample'
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../../lib/useSession'
|
||||
|
||||
interface RequireAuthProps {
|
||||
children: ReactNode
|
||||
fallbackPath?: string
|
||||
}
|
||||
|
||||
export function RequireAuth({ children, fallbackPath = '/login' }: RequireAuthProps) {
|
||||
const { session, loading, error } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
console.log('[RequireAuth Debug] Auth check:', {
|
||||
hasSession: !!session,
|
||||
loading,
|
||||
hasError: !!error,
|
||||
currentPath: location.pathname,
|
||||
fallbackPath
|
||||
})
|
||||
|
||||
// Show loading spinner while checking auth
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Checking authentication...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!session) {
|
||||
const redirectPath = `${fallbackPath}?next=${encodeURIComponent(location.pathname + location.search)}`
|
||||
console.log('[RequireAuth Debug] Redirecting to login:', redirectPath)
|
||||
return <Navigate to={redirectPath} replace />
|
||||
}
|
||||
|
||||
// Render protected content
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default RequireAuth
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
// Debug helper to log Supabase configuration
|
||||
const debugConfig = () => {
|
||||
const url = import.meta.env.VITE_SUPABASE_URL
|
||||
const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY
|
||||
|
||||
console.log('[Supabase Debug] Configuration:', {
|
||||
url: url ? '✓ URL configured' : '✗ URL missing',
|
||||
key: key ? '✓ Key configured' : '✗ Key missing',
|
||||
urlValue: url || 'undefined',
|
||||
keyValue: key ? `${key.substring(0, 20)}...` : 'undefined'
|
||||
})
|
||||
|
||||
return { url, key }
|
||||
}
|
||||
|
||||
const config = debugConfig()
|
||||
|
||||
if (!config.url) {
|
||||
throw new Error('Missing VITE_SUPABASE_URL environment variable')
|
||||
}
|
||||
|
||||
if (!config.key) {
|
||||
throw new Error('Missing VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY environment variable')
|
||||
}
|
||||
|
||||
export const supabase = createClient(
|
||||
config.url,
|
||||
config.key,
|
||||
{
|
||||
auth: {
|
||||
persistSession: true, // keep session in localStorage
|
||||
autoRefreshToken: true,
|
||||
detectSessionInUrl: true, // helpful on first load after redirect
|
||||
debug: import.meta.env.DEV, // Enable debug logs in development
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Debug helper for auth events
|
||||
export const debugAuthEvents = () => {
|
||||
supabase.auth.onAuthStateChange((event, session) => {
|
||||
console.log('[Supabase Debug] Auth state change:', {
|
||||
event,
|
||||
hasSession: !!session,
|
||||
userId: session?.user?.id,
|
||||
email: session?.user?.email,
|
||||
provider: session?.user?.app_metadata?.provider,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Call this in development to enable auth debugging
|
||||
if (import.meta.env.DEV) {
|
||||
debugAuthEvents()
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
||||
import { supabase } from './supabase'
|
||||
import type { Session, User, AuthError } from '@supabase/supabase-js'
|
||||
|
||||
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 () => {}
|
||||
})
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<AuthError | null>(null)
|
||||
|
||||
const refreshSession = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const { data, error } = await supabase.auth.refreshSession()
|
||||
|
||||
if (error) {
|
||||
console.error('[Auth Debug] Session refresh error:', error)
|
||||
setError(error)
|
||||
setSession(null)
|
||||
} else {
|
||||
console.log('[Auth Debug] Session refreshed successfully')
|
||||
setSession(data.session)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth Debug] Unexpected error during session refresh:', err)
|
||||
setError(err as AuthError)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signOut = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const { error } = await supabase.auth.signOut()
|
||||
|
||||
if (error) {
|
||||
console.error('[Auth Debug] Sign out error:', error)
|
||||
setError(error)
|
||||
} else {
|
||||
console.log('[Auth Debug] Signed out successfully')
|
||||
setSession(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth Debug] Unexpected error during sign out:', err)
|
||||
setError(err as AuthError)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
// Load current session on first mount
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
console.log('[Auth Debug] Initializing auth...')
|
||||
const { data, error } = await supabase.auth.getSession()
|
||||
|
||||
if (!mounted) return
|
||||
|
||||
if (error) {
|
||||
console.error('[Auth Debug] Initial session error:', error)
|
||||
setError(error)
|
||||
} else {
|
||||
console.log('[Auth Debug] Initial session loaded:', {
|
||||
hasSession: !!data.session,
|
||||
userId: data.session?.user?.id,
|
||||
email: data.session?.user?.email
|
||||
})
|
||||
setSession(data.session)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth Debug] Unexpected error during auth initialization:', err)
|
||||
if (mounted) {
|
||||
setError(err as AuthError)
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initializeAuth()
|
||||
|
||||
// Subscribe to auth state changes
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
async (event, newSession) => {
|
||||
if (!mounted) return
|
||||
|
||||
console.log('[Auth Debug] Auth state change:', {
|
||||
event,
|
||||
hasSession: !!newSession,
|
||||
userId: newSession?.user?.id,
|
||||
email: newSession?.user?.email,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
// Don't run supabase calls inside this callback; schedule them
|
||||
setTimeout(() => {
|
||||
if (mounted) {
|
||||
setSession(newSession)
|
||||
setError(null)
|
||||
|
||||
// Additional handling for specific events
|
||||
if (event === 'SIGNED_OUT') {
|
||||
console.log('[Auth Debug] User signed out, clearing session')
|
||||
} else if (event === 'SIGNED_IN') {
|
||||
console.log('[Auth Debug] User signed in successfully')
|
||||
} else if (event === 'TOKEN_REFRESHED') {
|
||||
console.log('[Auth Debug] Token refreshed')
|
||||
} else if (event === 'USER_UPDATED') {
|
||||
console.log('[Auth Debug] 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>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
// Debug hook to expose auth state for debugging
|
||||
export function useAuthDebug() {
|
||||
const auth = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[Auth Debug] Current auth state:', {
|
||||
hasSession: !!auth.session,
|
||||
hasUser: !!auth.user,
|
||||
loading: auth.loading,
|
||||
hasError: !!auth.error,
|
||||
userId: auth.user?.id,
|
||||
email: auth.user?.email,
|
||||
provider: auth.user?.app_metadata?.provider
|
||||
})
|
||||
}, [auth.session, auth.user, auth.loading, auth.error])
|
||||
|
||||
return auth
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { supabase } from '../lib/supabase'
|
||||
|
||||
interface CallbackState {
|
||||
status: 'processing' | 'success' | 'error'
|
||||
message: string
|
||||
details?: Record<string, any>
|
||||
}
|
||||
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate()
|
||||
const [state, setState] = useState<CallbackState>({
|
||||
status: 'processing',
|
||||
message: 'Processing authentication...'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get('code')
|
||||
const error = url.searchParams.get('error')
|
||||
const errorDescription = url.searchParams.get('error_description')
|
||||
const next = url.searchParams.get('next') || '/'
|
||||
|
||||
console.log('[Auth Callback Debug] URL parameters:', {
|
||||
hasCode: !!code,
|
||||
hasError: !!error,
|
||||
error,
|
||||
errorDescription,
|
||||
next,
|
||||
fullUrl: window.location.href
|
||||
})
|
||||
|
||||
// Handle OAuth errors
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
console.error('[Auth Callback Debug] OAuth error:', { error, errorDescription })
|
||||
|
||||
setState({
|
||||
status: 'error',
|
||||
message: `Authentication failed: ${errorMsg}`,
|
||||
details: { error, errorDescription }
|
||||
})
|
||||
|
||||
// Redirect to login page after 3 seconds
|
||||
setTimeout(() => navigate('/login', { replace: true }), 3000)
|
||||
return
|
||||
}
|
||||
|
||||
// If PKCE/SSR-style code is present, exchange it for a session
|
||||
if (code) {
|
||||
console.log('[Auth Callback Debug] Exchanging code for session...')
|
||||
|
||||
setState({
|
||||
status: 'processing',
|
||||
message: 'Exchanging authorization code...'
|
||||
})
|
||||
|
||||
const { data, error: exchangeError } = await supabase.auth.exchangeCodeForSession(code)
|
||||
|
||||
if (exchangeError) {
|
||||
console.error('[Auth Callback Debug] Code exchange error:', exchangeError)
|
||||
|
||||
setState({
|
||||
status: 'error',
|
||||
message: `Failed to complete sign in: ${exchangeError.message}`,
|
||||
details: { exchangeError }
|
||||
})
|
||||
|
||||
setTimeout(() => navigate('/login', { replace: true }), 3000)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[Auth Callback Debug] Code exchange successful:', {
|
||||
hasSession: !!data.session,
|
||||
userId: data.session?.user?.id,
|
||||
email: data.session?.user?.email
|
||||
})
|
||||
|
||||
setState({
|
||||
status: 'success',
|
||||
message: 'Sign in successful! Redirecting...',
|
||||
details: {
|
||||
userId: data.session?.user?.id,
|
||||
email: data.session?.user?.email,
|
||||
provider: data.session?.user?.app_metadata?.provider
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// No code present - might already be authenticated
|
||||
console.log('[Auth Callback Debug] No code present, checking existing session...')
|
||||
|
||||
const { data: sessionData } = await supabase.auth.getSession()
|
||||
|
||||
if (sessionData.session) {
|
||||
console.log('[Auth Callback Debug] Existing session found')
|
||||
setState({
|
||||
status: 'success',
|
||||
message: 'Already signed in! Redirecting...'
|
||||
})
|
||||
} else {
|
||||
console.log('[Auth Callback Debug] No session found')
|
||||
setState({
|
||||
status: 'error',
|
||||
message: 'No authentication data found'
|
||||
})
|
||||
setTimeout(() => navigate('/login', { replace: true }), 2000)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to the intended destination
|
||||
const destination = next.startsWith('/') ? next : '/'
|
||||
console.log('[Auth Callback Debug] Redirecting to:', destination)
|
||||
|
||||
setTimeout(() => navigate(destination, { replace: true }), 1500)
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Auth Callback Debug] Unexpected error:', err)
|
||||
|
||||
setState({
|
||||
status: 'error',
|
||||
message: `Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
||||
details: { error: err }
|
||||
})
|
||||
|
||||
setTimeout(() => navigate('/login', { replace: true }), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback()
|
||||
}, [navigate])
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (state.status) {
|
||||
case 'processing': return 'text-blue-600'
|
||||
case 'success': return 'text-green-600'
|
||||
case 'error': return 'text-red-600'
|
||||
default: return 'text-gray-600'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (state.status) {
|
||||
case 'processing': return '🔄'
|
||||
case 'success': return '✅'
|
||||
case 'error': return '❌'
|
||||
default: return '⏳'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-4">{getStatusIcon()}</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Authentication
|
||||
</h1>
|
||||
<p className={`text-lg ${getStatusColor()}`}>
|
||||
{state.message}
|
||||
</p>
|
||||
|
||||
{import.meta.env.DEV && state.details && (
|
||||
<details className="mt-6 text-left">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
Debug Information
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(state.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{state.status === 'processing' && (
|
||||
<div className="mt-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,800 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../lib/useSession'
|
||||
import { supabase } from '../lib/supabase'
|
||||
|
||||
export default function AuthDebug() {
|
||||
const { session, user, loading, error, signOut, refreshSession } = useAuth()
|
||||
const [testResults, setTestResults] = useState<any>(null)
|
||||
const [isTestingAuth, setIsTestingAuth] = useState(false)
|
||||
|
||||
// JWT API request state
|
||||
const [apiUrl, setApiUrl] = useState(`${window.location.origin}/api/v1/admin/settings`)
|
||||
const [apiMethod, setApiMethod] = useState<'GET' | 'POST' | 'PUT' | 'DELETE'>('GET')
|
||||
const [apiRequestBody, setApiRequestBody] = useState('')
|
||||
const [apiResponse, setApiResponse] = useState<any>(null)
|
||||
const [isTestingApi, setIsTestingApi] = useState(false)
|
||||
|
||||
// Admin functions state
|
||||
const [inviteEmail, setInviteEmail] = useState('')
|
||||
const [newEmail, setNewEmail] = useState('')
|
||||
const [isProcessingAdmin, setIsProcessingAdmin] = useState(false)
|
||||
|
||||
const runAuthTests = async () => {
|
||||
setIsTestingAuth(true)
|
||||
setTestResults(null)
|
||||
|
||||
const results: any = {
|
||||
timestamp: new Date().toISOString(),
|
||||
tests: {}
|
||||
}
|
||||
|
||||
try {
|
||||
// Test 1: Get current session
|
||||
console.log('[Auth Debug] Testing current session...')
|
||||
const { data: sessionData, error: sessionError } = await supabase.auth.getSession()
|
||||
results.tests.currentSession = {
|
||||
success: !sessionError,
|
||||
hasSession: !!sessionData.session,
|
||||
error: sessionError?.message,
|
||||
userId: sessionData.session?.user?.id,
|
||||
email: sessionData.session?.user?.email
|
||||
}
|
||||
|
||||
// Test 2: Get current user
|
||||
console.log('[Auth Debug] Testing current user...')
|
||||
const { data: userData, error: userError } = await supabase.auth.getUser()
|
||||
results.tests.currentUser = {
|
||||
success: !userError,
|
||||
hasUser: !!userData.user,
|
||||
error: userError?.message,
|
||||
userId: userData.user?.id,
|
||||
email: userData.user?.email
|
||||
}
|
||||
|
||||
// Test 3: Environment variables
|
||||
results.tests.environment = {
|
||||
supabaseUrl: import.meta.env.VITE_SUPABASE_URL || 'MISSING',
|
||||
supabaseKey: import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY ? 'CONFIGURED' : 'MISSING',
|
||||
mode: import.meta.env.MODE,
|
||||
dev: import.meta.env.DEV
|
||||
}
|
||||
|
||||
// Test 4: Local storage
|
||||
results.tests.localStorage = {
|
||||
hasSupabaseSession: !!localStorage.getItem('sb-nrlkjfznsavsbmweiyqu-auth-token'),
|
||||
keys: Object.keys(localStorage).filter(key => key.includes('supabase') || key.includes('sb-'))
|
||||
}
|
||||
|
||||
// Test 5: Context state
|
||||
results.tests.contextState = {
|
||||
hasSession: !!session,
|
||||
hasUser: !!user,
|
||||
loading,
|
||||
hasError: !!error,
|
||||
errorMessage: error?.message
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
results.tests.unexpectedError = {
|
||||
message: err instanceof Error ? err.message : 'Unknown error',
|
||||
error: err
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Auth Debug] Test results:', results)
|
||||
setTestResults(results)
|
||||
setIsTestingAuth(false)
|
||||
}
|
||||
|
||||
const clearLocalStorage = () => {
|
||||
const keys = Object.keys(localStorage).filter(key =>
|
||||
key.includes('supabase') || key.includes('sb-')
|
||||
)
|
||||
|
||||
keys.forEach(key => localStorage.removeItem(key))
|
||||
|
||||
console.log('[Auth Debug] Cleared local storage keys:', keys)
|
||||
alert(`Cleared ${keys.length} auth-related localStorage keys`)
|
||||
}
|
||||
|
||||
const testSignIn = async (provider: 'github' | 'google' | 'facebook' | 'linkedin_oidc' = 'github') => {
|
||||
try {
|
||||
// Supabase redirects back to your app after OAuth
|
||||
const redirectTo = `${window.location.origin}/auth/callback`
|
||||
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: {
|
||||
redirectTo,
|
||||
queryParams: provider === 'facebook'
|
||||
? { scope: 'email' }
|
||||
: provider === 'linkedin_oidc'
|
||||
? { scope: 'openid profile email' }
|
||||
: {
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
alert(`${provider} sign in test failed: ${error.message}`)
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`${provider} sign in test error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
const testApiRequest = async () => {
|
||||
if (!session?.access_token) {
|
||||
setApiResponse({
|
||||
error: 'No JWT token available. Please sign in first.',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsTestingApi(true)
|
||||
setApiResponse(null)
|
||||
|
||||
const requestData = {
|
||||
url: apiUrl,
|
||||
method: apiMethod,
|
||||
timestamp: new Date().toISOString(),
|
||||
jwt: session.access_token.substring(0, 20) + '...' // Show partial token for debug
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[API Debug] Making request with JWT:', requestData)
|
||||
|
||||
const requestOptions: RequestInit = {
|
||||
method: apiMethod,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
// Add request body for POST/PUT requests
|
||||
if ((apiMethod === 'POST' || apiMethod === 'PUT') && apiRequestBody.trim()) {
|
||||
try {
|
||||
JSON.parse(apiRequestBody) // Validate JSON
|
||||
requestOptions.body = apiRequestBody
|
||||
} catch (e) {
|
||||
setApiResponse({
|
||||
error: 'Invalid JSON in request body',
|
||||
timestamp: new Date().toISOString(),
|
||||
requestData
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, requestOptions)
|
||||
|
||||
let responseData: any
|
||||
const contentType = response.headers.get('content-type')
|
||||
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
responseData = await response.json()
|
||||
} else {
|
||||
responseData = await response.text()
|
||||
}
|
||||
|
||||
const result = {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
data: responseData,
|
||||
requestData,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
console.log('[API Debug] Response:', result)
|
||||
setApiResponse(result)
|
||||
|
||||
} catch (err) {
|
||||
const errorResult = {
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
requestData,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
console.error('[API Debug] Request failed:', errorResult)
|
||||
setApiResponse(errorResult)
|
||||
} finally {
|
||||
setIsTestingApi(false)
|
||||
}
|
||||
}
|
||||
|
||||
const inviteUser = async () => {
|
||||
if (!inviteEmail || !session) {
|
||||
alert('Please enter an email and ensure you are signed in')
|
||||
return
|
||||
}
|
||||
|
||||
// Show information about service role requirement
|
||||
const proceed = confirm(
|
||||
`⚠️ Admin Invite requires SERVICE ROLE permissions.\n\n` +
|
||||
`This will likely fail unless you:\n` +
|
||||
`1. Have a service role key configured\n` +
|
||||
`2. Are using RLS bypass\n` +
|
||||
`3. Have admin privileges\n\n` +
|
||||
`Alternative: Use the Magic Link feature instead.\n\n` +
|
||||
`Continue anyway?`
|
||||
)
|
||||
|
||||
if (!proceed) return
|
||||
|
||||
try {
|
||||
setIsProcessingAdmin(true)
|
||||
console.log('[Admin Debug] Inviting user:', inviteEmail)
|
||||
|
||||
// Note: This requires admin/service role permissions
|
||||
const { data, error } = await supabase.auth.admin.inviteUserByEmail(
|
||||
inviteEmail.trim(),
|
||||
{ redirectTo: `${window.location.origin}/welcome` }
|
||||
)
|
||||
|
||||
if (error) {
|
||||
console.error('[Admin Debug] Invite error:', error)
|
||||
|
||||
// Provide helpful error message
|
||||
let errorMsg = error.message
|
||||
if (error.message.includes('Bearer token') || error.message.includes('service role')) {
|
||||
errorMsg = `❌ Service Role Required\n\n` +
|
||||
`The invite function requires a service role key, not a user JWT.\n\n` +
|
||||
`Solutions:\n` +
|
||||
`• Use Magic Link instead (works with user permissions)\n` +
|
||||
`• Configure service role in backend\n` +
|
||||
`• Use Supabase Dashboard → Authentication → Users → Invite\n\n` +
|
||||
`Original error: ${error.message}`
|
||||
}
|
||||
|
||||
alert(errorMsg)
|
||||
} else {
|
||||
console.log('[Admin Debug] Invite successful:', data)
|
||||
alert(`✅ Invitation sent to ${inviteEmail}!`)
|
||||
setInviteEmail('')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Admin Debug] Invite unexpected error:', err)
|
||||
alert(`Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setIsProcessingAdmin(false)
|
||||
}
|
||||
}
|
||||
|
||||
const changeEmail = async () => {
|
||||
if (!newEmail || !session) {
|
||||
alert('Please enter a new email and ensure you are signed in')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsProcessingAdmin(true)
|
||||
console.log('[Admin Debug] Changing email to:', newEmail)
|
||||
|
||||
const { data, error } = await supabase.auth.updateUser({
|
||||
email: newEmail.trim()
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[Admin Debug] Email change error:', error)
|
||||
alert(`Failed to change email: ${error.message}`)
|
||||
} else {
|
||||
console.log('[Admin Debug] Email change initiated:', data)
|
||||
alert(`Email change confirmation sent to both ${user?.email} and ${newEmail}. Check both inboxes for confirmation links.`)
|
||||
setNewEmail('')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Admin Debug] Email change unexpected error:', err)
|
||||
alert(`Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setIsProcessingAdmin(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4 space-y-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Authentication Debug Panel
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Debug and test authentication functionality
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current Auth State */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Current Authentication State
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="p-3 bg-gray-50 rounded">
|
||||
<div className="text-sm font-medium text-gray-700">Loading</div>
|
||||
<div className={`text-lg ${loading ? 'text-yellow-600' : 'text-green-600'}`}>
|
||||
{loading ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded">
|
||||
<div className="text-sm font-medium text-gray-700">Has Session</div>
|
||||
<div className={`text-lg ${session ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{session ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded">
|
||||
<div className="text-sm font-medium text-gray-700">User ID</div>
|
||||
<div className="text-lg font-mono text-gray-900">
|
||||
{user?.id || 'None'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded">
|
||||
<div className="text-sm font-medium text-gray-700">Email</div>
|
||||
<div className="text-lg text-gray-900">
|
||||
{user?.email || 'None'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-md mb-4">
|
||||
<div className="text-red-800 text-sm font-medium">Authentication Error</div>
|
||||
<div className="text-red-700 text-sm">{error.message}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prominent JWT Token Display */}
|
||||
{session && (
|
||||
<div className="p-4 bg-yellow-50 border-2 border-yellow-300 rounded-lg mb-6">
|
||||
<h3 className="text-lg font-semibold text-yellow-900 mb-3 flex items-center">
|
||||
🔑 JWT Access Token
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-yellow-800 mb-1">
|
||||
Full Token (Click to select all):
|
||||
</label>
|
||||
<textarea
|
||||
value={session.access_token}
|
||||
readOnly
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
className="w-full h-32 px-3 py-2 border border-yellow-400 rounded-md bg-white font-mono text-xs resize-none focus:outline-none focus:ring-2 focus:ring-yellow-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 justify-between items-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(session.access_token || '')
|
||||
alert('JWT token copied to clipboard!')
|
||||
}}
|
||||
className="px-4 py-2 bg-yellow-600 text-white text-sm font-medium rounded hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-yellow-500"
|
||||
>
|
||||
📋 Copy Full Token
|
||||
</button>
|
||||
|
||||
<div className="text-yellow-800 text-xs">
|
||||
<div><strong>Expires:</strong> {session?.expires_at ? new Date(session.expires_at * 1000).toLocaleString() : 'Unknown'}</div>
|
||||
<div><strong>Length:</strong> {session.access_token?.length || 0} characters</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session && (
|
||||
<details className="mb-4">
|
||||
<summary className="cursor-pointer text-sm font-medium text-gray-700 hover:text-gray-900">
|
||||
Full Session Data
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto max-h-48">
|
||||
{JSON.stringify(session, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Actions</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={runAuthTests}
|
||||
disabled={isTestingAuth}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isTestingAuth ? 'Running Tests...' : 'Run Auth Tests'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={refreshSession}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
Refresh Session
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => testSignIn('github')}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||
>
|
||||
Test GitHub Sign In
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => testSignIn('google')}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Test Google Sign In
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => testSignIn('facebook')}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
|
||||
>
|
||||
Test Facebook Sign In
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => testSignIn('linkedin_oidc')}
|
||||
className="px-4 py-2 bg-cyan-600 text-white rounded hover:bg-cyan-700"
|
||||
>
|
||||
Test LinkedIn Sign In
|
||||
</button>
|
||||
|
||||
{session && (
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={clearLocalStorage}
|
||||
className="px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700"
|
||||
>
|
||||
Clear Local Storage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JWT API Request Testing */}
|
||||
{session && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
JWT API Request Testing
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Test authenticated requests to your backend using the JWT token
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* URL Input */}
|
||||
<div>
|
||||
<label htmlFor="api-url" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
API URL
|
||||
</label>
|
||||
<input
|
||||
id="api-url"
|
||||
type="url"
|
||||
value={apiUrl}
|
||||
onChange={(e) => setApiUrl(e.target.value)}
|
||||
placeholder="https://example.com/api/v1/admin/settings"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Method Selection */}
|
||||
<div>
|
||||
<label htmlFor="api-method" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
HTTP Method
|
||||
</label>
|
||||
<select
|
||||
id="api-method"
|
||||
value={apiMethod}
|
||||
onChange={(e) => setApiMethod(e.target.value as 'GET' | 'POST' | 'PUT' | 'DELETE')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Request Body (for POST/PUT) */}
|
||||
{(apiMethod === 'POST' || apiMethod === 'PUT') && (
|
||||
<div>
|
||||
<label htmlFor="api-body" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Request Body (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
id="api-body"
|
||||
value={apiRequestBody}
|
||||
onChange={(e) => setApiRequestBody(e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JWT Token Display */}
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
|
||||
<div className="text-sm font-medium text-green-900 mb-2">🔑 JWT Access Token (Full)</div>
|
||||
{session?.access_token ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={session.access_token}
|
||||
readOnly
|
||||
className="w-full h-24 px-3 py-2 border border-green-300 rounded-md bg-white font-mono text-xs resize-none focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
placeholder="No token available"
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(session.access_token || '')}
|
||||
className="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
📋 Copy Token
|
||||
</button>
|
||||
<span className="text-green-700 text-xs">
|
||||
Expires: {session?.expires_at ? new Date(session.expires_at * 1000).toLocaleString() : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-green-700 text-sm">No JWT token available. Please sign in first.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Send Request Button */}
|
||||
<button
|
||||
onClick={testApiRequest}
|
||||
disabled={isTestingApi || !apiUrl}
|
||||
className="w-full px-4 py-3 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{isTestingApi ? 'Sending Request...' : `Send ${apiMethod} Request`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Response */}
|
||||
{apiResponse && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">API Response</h2>
|
||||
|
||||
{apiResponse.error ? (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-md mb-4">
|
||||
<div className="text-red-800 text-sm font-medium">Request Failed</div>
|
||||
<div className="text-red-700 text-sm">{apiResponse.error}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`p-4 ${apiResponse.success ? 'bg-green-50 border-green-200' : 'bg-yellow-50 border-yellow-200'} border rounded-md mb-4`}>
|
||||
<div className={`text-sm font-medium ${apiResponse.success ? 'text-green-800' : 'text-yellow-800'}`}>
|
||||
{apiResponse.status} {apiResponse.statusText}
|
||||
</div>
|
||||
<div className={`text-sm ${apiResponse.success ? 'text-green-700' : 'text-yellow-700'}`}>
|
||||
Request {apiResponse.success ? 'successful' : 'completed with non-2xx status'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Response Headers */}
|
||||
{apiResponse.headers && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium text-gray-700 hover:text-gray-900">
|
||||
Response Headers
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto max-h-32">
|
||||
{JSON.stringify(apiResponse.headers, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Response Data */}
|
||||
<details open>
|
||||
<summary className="cursor-pointer text-sm font-medium text-gray-700 hover:text-gray-900">
|
||||
Response Data
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto max-h-96">
|
||||
{typeof apiResponse.data === 'string'
|
||||
? apiResponse.data
|
||||
: JSON.stringify(apiResponse.data, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
{/* Request Debug Info */}
|
||||
{apiResponse.requestData && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium text-gray-700 hover:text-gray-900">
|
||||
Request Debug Info
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto max-h-32">
|
||||
{JSON.stringify(apiResponse.requestData, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Functions */}
|
||||
{session && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
|
||||
👑 Admin Functions
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6 text-sm">
|
||||
Test admin-level authentication features (requires appropriate permissions)
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Invite User */}
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<h3 className="text-lg font-medium text-blue-900 mb-3">📨 Invite User</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-800 mb-1">
|
||||
Email Address to Invite:
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
className="w-full px-3 py-2 border border-blue-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={inviteUser}
|
||||
disabled={isProcessingAdmin || !inviteEmail}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessingAdmin ? 'Sending Invite...' : 'Try Admin Invite'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (inviteEmail) {
|
||||
supabase.auth.signInWithOtp({
|
||||
email: inviteEmail.trim(),
|
||||
options: { emailRedirectTo: `${window.location.origin}/auth/callback` }
|
||||
}).then(({ error }) => {
|
||||
if (error) alert(`Error: ${error.message}`)
|
||||
else {
|
||||
alert(`✅ Magic link sent to ${inviteEmail}!\n\nThey can use this to create an account and sign in.`)
|
||||
setInviteEmail('')
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
disabled={!inviteEmail}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Send Magic Link Instead
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-blue-700 space-y-1">
|
||||
<div><strong>⚠️ Admin Invite:</strong> Requires service role key (will likely fail from frontend)</div>
|
||||
<div><strong>✅ Magic Link:</strong> Works with user permissions, allows account creation</div>
|
||||
<div><strong>Alternative:</strong> Use Supabase Dashboard → Authentication → Users → Invite</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change Email */}
|
||||
<div className="p-4 bg-orange-50 border border-orange-200 rounded-md">
|
||||
<h3 className="text-lg font-medium text-orange-900 mb-3">📧 Change Email Address</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-orange-800 mb-1">
|
||||
New Email Address:
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
placeholder="new-email@example.com"
|
||||
className="w-full px-3 py-2 border border-orange-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={changeEmail}
|
||||
disabled={isProcessingAdmin || !newEmail}
|
||||
className="px-4 py-2 bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessingAdmin ? 'Processing...' : 'Change Email'}
|
||||
</button>
|
||||
<div className="text-xs text-orange-700 space-y-1">
|
||||
<div><strong>Current Email:</strong> {user?.email}</div>
|
||||
<div><strong>Note:</strong> Sends confirmation emails to both old and new addresses.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Admin Actions */}
|
||||
<div className="p-4 bg-purple-50 border border-purple-200 rounded-md">
|
||||
<h3 className="text-lg font-medium text-purple-900 mb-3">⚡ Quick Actions</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
const email = prompt('Enter email address for magic link:')
|
||||
if (email) {
|
||||
supabase.auth.signInWithOtp({
|
||||
email: email.trim(),
|
||||
options: { emailRedirectTo: `${window.location.origin}/auth/callback` }
|
||||
}).then(({ error }) => {
|
||||
if (error) alert(`Error: ${error.message}`)
|
||||
else alert(`Magic link sent to ${email}!`)
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 bg-purple-600 text-white text-sm rounded hover:bg-purple-700"
|
||||
>
|
||||
🪄 Send Magic Link
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const email = prompt('Enter email address for password reset:')
|
||||
if (email) {
|
||||
supabase.auth.resetPasswordForEmail(
|
||||
email.trim(),
|
||||
{ redirectTo: `${window.location.origin}/auth/reset` }
|
||||
).then(({ error }) => {
|
||||
if (error) alert(`Error: ${error.message}`)
|
||||
else alert(`Password reset sent to ${email}!`)
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 bg-purple-600 text-white text-sm rounded hover:bg-purple-700"
|
||||
>
|
||||
🔑 Reset Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Results */}
|
||||
{testResults && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Test Results</h2>
|
||||
<pre className="p-4 bg-gray-100 rounded text-sm overflow-auto max-h-96">
|
||||
{JSON.stringify(testResults, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment Info */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Environment</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div><strong>Mode:</strong> {import.meta.env.MODE}</div>
|
||||
<div><strong>Dev:</strong> {import.meta.env.DEV ? 'Yes' : 'No'}</div>
|
||||
<div><strong>Supabase URL:</strong> {import.meta.env.VITE_SUPABASE_URL || 'NOT SET'}</div>
|
||||
<div><strong>Supabase Key:</strong> {import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY ? 'CONFIGURED' : 'NOT SET'}</div>
|
||||
<div><strong>Origin:</strong> {window.location.origin}</div>
|
||||
<div><strong>Callback URL:</strong> {window.location.origin}/auth/callback</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { useAuth } from '../lib/useSession'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const { session, user, loading, signOut } = useAuth()
|
||||
const [isSigningIn, setIsSigningIn] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [debugInfo, setDebugInfo] = useState<any>(null)
|
||||
|
||||
// Show logged in state instead of redirecting
|
||||
if (session && !loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-2xl w-full bg-white rounded-lg shadow-md p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-6xl mb-4">✅</div>
|
||||
<h1 className="text-3xl font-bold text-green-600 mb-2">
|
||||
YOU ARE LOGGED IN
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Successfully authenticated with Supabase
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* User Info Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<div className="text-sm font-medium text-blue-900 mb-1">User ID</div>
|
||||
<div className="font-mono text-blue-800 break-all text-sm">
|
||||
{user?.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
|
||||
<div className="text-sm font-medium text-green-900 mb-1">Email</div>
|
||||
<div className="text-green-800">
|
||||
{user?.email}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-purple-50 border border-purple-200 rounded-md">
|
||||
<div className="text-sm font-medium text-purple-900 mb-1">Provider</div>
|
||||
<div className="text-purple-800">
|
||||
{user?.app_metadata?.provider || 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<div className="text-sm font-medium text-yellow-900 mb-1">Created</div>
|
||||
<div className="text-yellow-800 text-sm">
|
||||
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JWT Token Display */}
|
||||
<div className="p-4 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<div className="text-sm font-medium text-gray-900 mb-2">JWT Access Token</div>
|
||||
<div className="font-mono text-xs bg-white p-3 rounded border break-all text-gray-800">
|
||||
{session?.access_token}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-600">
|
||||
<strong>Expires:</strong> {session?.expires_at ? new Date(session.expires_at * 1000).toLocaleString() : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refresh Token (if available) */}
|
||||
{session?.refresh_token && (
|
||||
<div className="p-4 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<div className="text-sm font-medium text-gray-900 mb-2">Refresh Token</div>
|
||||
<div className="font-mono text-xs bg-white p-3 rounded border break-all text-gray-800">
|
||||
{session.refresh_token}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Metadata */}
|
||||
{(user?.user_metadata && Object.keys(user.user_metadata).length > 0) && (
|
||||
<details className="p-4 bg-indigo-50 border border-indigo-200 rounded-md">
|
||||
<summary className="cursor-pointer text-sm font-medium text-indigo-900 hover:text-indigo-700">
|
||||
User Metadata
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-white rounded text-xs overflow-auto max-h-32 text-gray-800">
|
||||
{JSON.stringify(user.user_metadata, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* App Metadata */}
|
||||
{(user?.app_metadata && Object.keys(user.app_metadata).length > 0) && (
|
||||
<details className="p-4 bg-orange-50 border border-orange-200 rounded-md">
|
||||
<summary className="cursor-pointer text-sm font-medium text-orange-900 hover:text-orange-700">
|
||||
App Metadata
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-white rounded text-xs overflow-auto max-h-32 text-gray-800">
|
||||
{JSON.stringify(user.app_metadata, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Full Session Data */}
|
||||
<details className="p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<summary className="cursor-pointer text-sm font-medium text-red-900 hover:text-red-700">
|
||||
Full Session Object
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-white rounded text-xs overflow-auto max-h-48 text-gray-800">
|
||||
{JSON.stringify(session, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-3 pt-4 border-t">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Go to Home
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/debug')}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||
>
|
||||
Debug Panel
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
Refresh Session
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
await signOut()
|
||||
window.location.reload()
|
||||
}}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const signInWithOAuth = async (provider: 'github' | 'google' | 'facebook' | 'linkedin_oidc', nextPath = '/') => {
|
||||
try {
|
||||
setIsSigningIn(true)
|
||||
setError(null)
|
||||
setDebugInfo(null)
|
||||
|
||||
// Supabase redirects back to your app after OAuth
|
||||
const redirectTo = `${window.location.origin}/auth/callback?next=${encodeURIComponent(nextPath)}`
|
||||
|
||||
console.log(`[Login Debug] Initiating ${provider} OAuth:`, {
|
||||
provider,
|
||||
redirectTo,
|
||||
nextPath,
|
||||
origin: window.location.origin
|
||||
})
|
||||
|
||||
const oauthOptions: any = {
|
||||
redirectTo
|
||||
}
|
||||
|
||||
// Provider-specific options
|
||||
if (provider === 'github') {
|
||||
oauthOptions.queryParams = {
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
}
|
||||
} else if (provider === 'google') {
|
||||
oauthOptions.queryParams = {
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
}
|
||||
} else if (provider === 'facebook') {
|
||||
oauthOptions.queryParams = {
|
||||
scope: 'email',
|
||||
}
|
||||
} else if (provider === 'linkedin_oidc') {
|
||||
oauthOptions.queryParams = {
|
||||
scope: 'openid profile email',
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: oauthOptions
|
||||
})
|
||||
|
||||
console.log(`[Login Debug] ${provider} OAuth response:`, { data, error })
|
||||
|
||||
if (error) {
|
||||
console.error(`[Login Debug] ${provider} OAuth initiation error:`, error)
|
||||
setError(`Failed to initiate ${provider} sign in: ${error.message}`)
|
||||
setDebugInfo({ provider, error })
|
||||
} else {
|
||||
console.log(`[Login Debug] ${provider} OAuth initiated successfully, redirecting...`)
|
||||
// OAuth redirect should happen automatically
|
||||
// If we reach here without redirect, there might be an issue
|
||||
const expectedDomain = provider === 'github'
|
||||
? 'github.com'
|
||||
: provider === 'google'
|
||||
? 'accounts.google.com'
|
||||
: provider === 'facebook'
|
||||
? 'facebook.com'
|
||||
: 'linkedin.com'
|
||||
setTimeout(() => {
|
||||
if (!window.location.href.includes(expectedDomain)) {
|
||||
setError('OAuth redirect did not occur as expected')
|
||||
setDebugInfo({
|
||||
provider,
|
||||
message: `Expected redirect to ${expectedDomain} but still on our domain`,
|
||||
currentUrl: window.location.href
|
||||
})
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Login Debug] ${provider} unexpected error:`, err)
|
||||
setError(`Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
setDebugInfo({ provider, error: err })
|
||||
} finally {
|
||||
setIsSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithGitHub = (nextPath = '/') => signInWithOAuth('github', nextPath)
|
||||
const signInWithGoogle = (nextPath = '/') => signInWithOAuth('google', nextPath)
|
||||
const signInWithFacebook = (nextPath = '/') => signInWithOAuth('facebook', nextPath)
|
||||
const signInWithLinkedIn = (nextPath = '/') => signInWithOAuth('linkedin_oidc', nextPath)
|
||||
|
||||
const testSupabaseConnection = async () => {
|
||||
try {
|
||||
console.log('[Login Debug] Testing Supabase connection...')
|
||||
setError(null)
|
||||
|
||||
// Test basic connection
|
||||
const { data, error } = await supabase.auth.getSession()
|
||||
|
||||
const testResult = {
|
||||
connectionSuccess: !error,
|
||||
hasSession: !!data.session,
|
||||
error: error?.message,
|
||||
url: supabase.supabaseUrl,
|
||||
key: supabase.supabaseKey.substring(0, 20) + '...'
|
||||
}
|
||||
|
||||
console.log('[Login Debug] Connection test result:', testResult)
|
||||
setDebugInfo(testResult)
|
||||
|
||||
if (error) {
|
||||
setError(`Connection test failed: ${error.message}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Login Debug] Connection test error:', err)
|
||||
setError(`Connection test error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center p-4"
|
||||
style={{
|
||||
backgroundColor: '#f9fafb',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-white rounded-xl shadow-lg p-6"
|
||||
style={{
|
||||
maxWidth: '384px',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)'
|
||||
}}
|
||||
>
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-4xl mb-3">🔐</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Sign In
|
||||
</h1>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Choose your preferred authentication method
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-800 text-xs font-medium">Error</p>
|
||||
<p className="text-red-700 text-xs">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* GitHub Login */}
|
||||
<button
|
||||
onClick={() => signInWithGitHub()}
|
||||
disabled={isSigningIn}
|
||||
className="w-full flex items-center justify-center px-4 py-2.5 border border-gray-300 rounded-lg bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 hover:border-gray-400 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '10px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#374151',
|
||||
cursor: isSigningIn ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningIn ? 0.5 : 1,
|
||||
transition: 'all 200ms ease-in-out'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSigningIn) {
|
||||
e.currentTarget.style.backgroundColor = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = '#9ca3af';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSigningIn) {
|
||||
e.currentTarget.style.backgroundColor = '#ffffff';
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-3" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</button>
|
||||
|
||||
{/* Google Login */}
|
||||
<button
|
||||
onClick={() => signInWithGoogle()}
|
||||
disabled={isSigningIn}
|
||||
className="w-full flex items-center justify-center px-4 py-2.5 border border-gray-300 rounded-lg bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-3" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Google
|
||||
</button>
|
||||
|
||||
{/* Facebook Login */}
|
||||
<button
|
||||
onClick={() => signInWithFacebook()}
|
||||
disabled={isSigningIn}
|
||||
className="w-full flex items-center justify-center px-4 py-2.5 border border-gray-300 rounded-lg bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-3" viewBox="0 0 24 24" fill="#1877F2">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
Facebook
|
||||
</button>
|
||||
|
||||
{import.meta.env.DEV && (
|
||||
<div className="pt-4 border-t border-gray-200 mt-6">
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs text-gray-500 hover:text-gray-700 list-none">
|
||||
<span className="flex items-center justify-center">
|
||||
<span>Development Tools</span>
|
||||
<svg className="w-4 h-4 ml-1 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="mt-3 space-y-2">
|
||||
<button
|
||||
onClick={testSupabaseConnection}
|
||||
className="w-full px-3 py-2 text-xs border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
<div className="text-xs text-gray-400 space-y-1 px-2">
|
||||
<p><strong>Mode:</strong> {import.meta.env.MODE}</p>
|
||||
<p><strong>Supabase:</strong> {import.meta.env.VITE_SUPABASE_URL ? '✓' : '✗'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{debugInfo && import.meta.env.DEV && (
|
||||
<details className="mt-6">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
Debug Information
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto max-h-48">
|
||||
{JSON.stringify(debugInfo, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-gray-400">
|
||||
Secure authentication via Supabase
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { useAuth } from '../lib/useSession'
|
||||
|
||||
export default function LoginCompact() {
|
||||
const navigate = useNavigate()
|
||||
const { session, user, loading, signOut } = useAuth()
|
||||
const [isSigningIn, setIsSigningIn] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showEmailForm, setShowEmailForm] = useState(false)
|
||||
const [showMagicLink, setShowMagicLink] = useState(false)
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [magicLinkEmail, setMagicLinkEmail] = useState('')
|
||||
const [resetEmail, setResetEmail] = useState('')
|
||||
|
||||
// Show logged in state if authenticated
|
||||
if (session && !loading) {
|
||||
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' }}>
|
||||
YOU ARE LOGGED IN
|
||||
</h1>
|
||||
<p style={{ color: '#6b7280', fontSize: '14px' }}>
|
||||
Email: {user?.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
style={{
|
||||
flex: '1',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Home
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/debug')}
|
||||
style={{
|
||||
flex: '1',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#8b5cf6',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Debug
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await signOut()
|
||||
window.location.reload()
|
||||
}}
|
||||
style={{
|
||||
flex: '1',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#ef4444',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const signInWithProvider = async (provider: 'github' | 'google' | 'facebook' | 'linkedin_oidc') => {
|
||||
try {
|
||||
setIsSigningIn(true)
|
||||
setError(null)
|
||||
|
||||
const redirectTo = `${window.location.origin}/auth/callback`
|
||||
console.log(`[LoginCompact] Signing in with ${provider}`)
|
||||
|
||||
const oauthOptions: any = { redirectTo }
|
||||
if (provider === 'facebook') {
|
||||
oauthOptions.queryParams = { scope: 'email' }
|
||||
} else if (provider === 'linkedin_oidc') {
|
||||
oauthOptions.queryParams = { scope: 'openid profile email' }
|
||||
} else {
|
||||
oauthOptions.queryParams = {
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: oauthOptions
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error(`[LoginCompact] ${provider} error:`, error)
|
||||
setError(`Failed to sign in with ${provider}: ${error.message}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[LoginCompact] Unexpected error:`, err)
|
||||
setError(`Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setIsSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithEmail = async () => {
|
||||
if (!email || !password) {
|
||||
setError('Please enter both email and password')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningIn(true)
|
||||
setError(null)
|
||||
|
||||
console.log('[LoginCompact] Signing in with email:', email)
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
password: password
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[LoginCompact] Email sign in error:', error)
|
||||
setError(error.message)
|
||||
} else if (data.user) {
|
||||
console.log('[LoginCompact] Email sign in successful')
|
||||
// User will be redirected by the auth state change
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[LoginCompact] Unexpected error:', err)
|
||||
setError(`Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setIsSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithMagicLink = async () => {
|
||||
if (!magicLinkEmail) {
|
||||
setError('Please enter your email address')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningIn(true)
|
||||
setError(null)
|
||||
|
||||
console.log('[LoginCompact] Sending magic link to:', magicLinkEmail)
|
||||
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email: magicLinkEmail.trim(),
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[LoginCompact] Magic link error:', error)
|
||||
setError(error.message)
|
||||
} else {
|
||||
setError(null)
|
||||
alert(`Magic link sent to ${magicLinkEmail}! Check your email and click the link to sign in.`)
|
||||
setMagicLinkEmail('')
|
||||
setShowMagicLink(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[LoginCompact] Unexpected error:', err)
|
||||
setError(`Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setIsSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetPassword = async () => {
|
||||
if (!resetEmail) {
|
||||
setError('Please enter your email address')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningIn(true)
|
||||
setError(null)
|
||||
|
||||
console.log('[LoginCompact] Sending password reset to:', resetEmail)
|
||||
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(
|
||||
resetEmail.trim(),
|
||||
{ redirectTo: `${window.location.origin}/auth/reset` }
|
||||
)
|
||||
|
||||
if (error) {
|
||||
console.error('[LoginCompact] Password reset error:', error)
|
||||
setError(error.message)
|
||||
} else {
|
||||
setError(null)
|
||||
alert(`Password reset link sent to ${resetEmail}! Check your email and follow the instructions.`)
|
||||
setResetEmail('')
|
||||
setShowPasswordReset(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[LoginCompact] Unexpected error:', err)
|
||||
setError(`Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setIsSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f3f4f6'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '32px', marginBottom: '16px' }}>⏳</div>
|
||||
<p style={{ color: '#6b7280' }}>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f3f4f6',
|
||||
padding: '16px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '320px',
|
||||
width: '100%',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.1)',
|
||||
padding: '24px'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
|
||||
<div style={{ fontSize: '40px', marginBottom: '12px' }}>🔐</div>
|
||||
<h1 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginBottom: '8px',
|
||||
margin: '0'
|
||||
}}>
|
||||
Sign In
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '13px',
|
||||
margin: '0'
|
||||
}}>
|
||||
Choose your authentication method
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<p style={{
|
||||
color: '#dc2626',
|
||||
fontSize: '12px',
|
||||
margin: '0'
|
||||
}}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email/Password Form */}
|
||||
{showEmailForm ? (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSigningIn && signInWithEmail()}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#ffffff',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSigningIn && signInWithEmail()}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#ffffff',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={signInWithEmail}
|
||||
disabled={isSigningIn || !email || !password}
|
||||
style={{
|
||||
flex: '1',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#059669',
|
||||
color: '#ffffff',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: isSigningIn || !email || !password ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningIn || !email || !password ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isSigningIn ? 'Signing In...' : 'Sign In'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEmailForm(false)
|
||||
setEmail('')
|
||||
setPassword('')
|
||||
setError(null)
|
||||
}}
|
||||
disabled={isSigningIn}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#374151',
|
||||
fontSize: '14px',
|
||||
cursor: isSigningIn ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningIn ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button
|
||||
onClick={() => navigate('/signup')}
|
||||
disabled={isSigningIn}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#3b82f6',
|
||||
fontSize: '13px',
|
||||
cursor: isSigningIn ? 'not-allowed' : 'pointer',
|
||||
textDecoration: 'underline',
|
||||
opacity: isSigningIn ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Don't have an account? Sign up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : showMagicLink ? (
|
||||
/* Magic Link Form */
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Enter your email address"
|
||||
value={magicLinkEmail}
|
||||
onChange={(e) => setMagicLinkEmail(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSigningIn && signInWithMagicLink()}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#ffffff',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={signInWithMagicLink}
|
||||
disabled={isSigningIn || !magicLinkEmail}
|
||||
style={{
|
||||
flex: '1',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#7c3aed',
|
||||
color: '#ffffff',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: isSigningIn || !magicLinkEmail ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningIn || !magicLinkEmail ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isSigningIn ? 'Sending...' : 'Send Magic Link'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMagicLink(false)
|
||||
setMagicLinkEmail('')
|
||||
setError(null)
|
||||
}}
|
||||
disabled={isSigningIn}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#374151',
|
||||
fontSize: '14px',
|
||||
cursor: isSigningIn ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningIn ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', fontSize: '12px', color: '#6b7280' }}>
|
||||
We'll send you a secure link to sign in without a password
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : showPasswordReset ? (
|
||||
/* Password Reset Form */
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Enter your email address"
|
||||
value={resetEmail}
|
||||
onChange={(e) => setResetEmail(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSigningIn && resetPassword()}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#ffffff',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={resetPassword}
|
||||
disabled={isSigningIn || !resetEmail}
|
||||
style={{
|
||||
flex: '1',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#dc2626',
|
||||
color: '#ffffff',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: isSigningIn || !resetEmail ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningIn || !resetEmail ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isSigningIn ? 'Sending...' : 'Reset Password'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPasswordReset(false)
|
||||
setResetEmail('')
|
||||
setError(null)
|
||||
}}
|
||||
disabled={isSigningIn}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#374151',
|
||||
fontSize: '14px',
|
||||
cursor: isSigningIn ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningIn ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', fontSize: '12px', color: '#6b7280' }}>
|
||||
We'll send you instructions to reset your password
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Auth Method Toggles */}
|
||||
<div style={{ marginBottom: '16px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEmailForm(true)
|
||||
setShowMagicLink(false)
|
||||
setShowPasswordReset(false)
|
||||
setError(null)
|
||||
}}
|
||||
disabled={isSigningIn}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
border: '2px solid #059669',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#f0fdf4',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#059669',
|
||||
cursor: isSigningIn ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningIn ? 0.6 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px'
|
||||
}}
|
||||
>
|
||||
📧 Email & Password
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMagicLink(true)
|
||||
setShowEmailForm(false)
|
||||
setShowPasswordReset(false)
|
||||
setError(null)
|
||||
}}
|
||||
disabled={isSigningIn}
|
||||
style={{
|
||||
flex: '1',
|
||||
padding: '10px 16px',
|
||||
border: '2px solid #7c3aed',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#faf5ff',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#7c3aed',
|
||||
cursor: isSigningIn ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningIn ? 0.6 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px'
|
||||
}}
|
||||
>
|
||||
🪄 Magic Link
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPasswordReset(true)
|
||||
setShowEmailForm(false)
|
||||
setShowMagicLink(false)
|
||||
setError(null)
|
||||
}}
|
||||
disabled={isSigningIn}
|
||||
style={{
|
||||
flex: '1',
|
||||
padding: '10px 16px',
|
||||
border: '2px solid #dc2626',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#fef2f2',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#dc2626',
|
||||
cursor: isSigningIn ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningIn ? 0.6 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px'
|
||||
}}
|
||||
>
|
||||
🔑 Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
margin: '16px 0',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '0',
|
||||
right: '0',
|
||||
height: '1px',
|
||||
backgroundColor: '#e5e7eb'
|
||||
}} />
|
||||
<span style={{
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#6b7280',
|
||||
fontSize: '12px',
|
||||
padding: '0 12px'
|
||||
}}>
|
||||
or continue with
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* OAuth Buttons Container */}
|
||||
{!showEmailForm && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{/* GitHub */}
|
||||
<button
|
||||
onClick={() => signInWithProvider('github')}
|
||||
disabled={isSigningIn}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '10px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#374151',
|
||||
cursor: isSigningIn ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningIn ? 0.6 : 1,
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</button>
|
||||
|
||||
{/* Google */}
|
||||
<button
|
||||
onClick={() => signInWithProvider('google')}
|
||||
disabled={isSigningIn}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '10px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#374151',
|
||||
cursor: isSigningIn ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningIn ? 0.6 : 1,
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Google
|
||||
</button>
|
||||
|
||||
{/* Facebook */}
|
||||
<button
|
||||
onClick={() => signInWithProvider('facebook')}
|
||||
disabled={isSigningIn}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '10px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#374151',
|
||||
cursor: isSigningIn ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningIn ? 0.6 : 1,
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="#1877F2">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
Facebook
|
||||
</button>
|
||||
|
||||
{/* LinkedIn */}
|
||||
<button
|
||||
onClick={() => signInWithProvider('linkedin_oidc')}
|
||||
disabled={isSigningIn}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '10px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#374151',
|
||||
cursor: isSigningIn ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningIn ? 0.6 : 1,
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="#0A66C2">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
LinkedIn
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '20px',
|
||||
paddingTop: '16px',
|
||||
borderTop: '1px solid #e5e7eb'
|
||||
}}>
|
||||
<p style={{
|
||||
color: '#9ca3af',
|
||||
fontSize: '11px',
|
||||
margin: '0'
|
||||
}}>
|
||||
Powered by Supabase Auth
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../lib/useSession'
|
||||
|
||||
// Simplified login page for testing without redirect logic
|
||||
export default function LoginTest() {
|
||||
const { session, loading, error } = useAuth()
|
||||
const [debugInfo, setDebugInfo] = useState<any>(null)
|
||||
|
||||
console.log('[LoginTest Debug] Component rendered:', {
|
||||
hasSession: !!session,
|
||||
loading,
|
||||
hasError: !!error,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
const testConnection = () => {
|
||||
const info = {
|
||||
authState: {
|
||||
hasSession: !!session,
|
||||
loading,
|
||||
hasError: !!error,
|
||||
errorMessage: error?.message
|
||||
},
|
||||
environment: {
|
||||
supabaseUrl: import.meta.env.VITE_SUPABASE_URL,
|
||||
hasKey: !!import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY,
|
||||
mode: import.meta.env.MODE
|
||||
},
|
||||
location: {
|
||||
href: window.location.href,
|
||||
pathname: window.location.pathname,
|
||||
origin: window.location.origin
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[LoginTest Debug] Connection test:', info)
|
||||
setDebugInfo(info)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4 text-center">
|
||||
Login Test Page
|
||||
</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<h3 className="font-medium text-blue-900 mb-2">Auth Status</h3>
|
||||
<div className="text-sm text-blue-800 space-y-1">
|
||||
<div>Loading: {loading ? 'Yes' : 'No'}</div>
|
||||
<div>Has Session: {session ? 'Yes' : 'No'}</div>
|
||||
<div>Error: {error ? error.message : 'None'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={testConnection}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
|
||||
{debugInfo && (
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm font-medium text-gray-700">
|
||||
Debug Info
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto max-h-48">
|
||||
{JSON.stringify(debugInfo, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-sm text-gray-600">
|
||||
If you can see this page, routing is working
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Path: {window.location.pathname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { supabase } from '../lib/supabase'
|
||||
|
||||
export default function Signup() {
|
||||
const navigate = useNavigate()
|
||||
const [isSigningUp, setIsSigningUp] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
|
||||
const validateForm = () => {
|
||||
if (!email || !password || !confirmPassword) {
|
||||
setError('Please fill in all fields')
|
||||
return false
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return false
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters long')
|
||||
return false
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(email)) {
|
||||
setError('Please enter a valid email address')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const signUp = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
try {
|
||||
setIsSigningUp(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
console.log('[Signup] Creating account for:', email)
|
||||
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[Signup] Sign up error:', error)
|
||||
setError(error.message)
|
||||
} else if (data.user) {
|
||||
console.log('[Signup] Sign up successful:', data.user)
|
||||
|
||||
// Check if email confirmation is required
|
||||
if (data.user && !data.session) {
|
||||
setSuccess('Check your email for a confirmation link to complete your registration.')
|
||||
} else {
|
||||
setSuccess('Account created successfully! You can now sign in.')
|
||||
setTimeout(() => navigate('/login'), 2000)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Signup] Unexpected error:', err)
|
||||
setError(`Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setIsSigningUp(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f3f4f6',
|
||||
padding: '16px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.1)',
|
||||
padding: '32px'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||
<div style={{ fontSize: '40px', marginBottom: '12px' }}>🚀</div>
|
||||
<h1 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginBottom: '8px',
|
||||
margin: '0'
|
||||
}}>
|
||||
Create Account
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
margin: '0'
|
||||
}}>
|
||||
Join Stirling PDF to get started
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0fdf4',
|
||||
border: '1px solid #bbf7d0',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<p style={{
|
||||
color: '#059669',
|
||||
fontSize: '14px',
|
||||
margin: '0'
|
||||
}}>
|
||||
{success}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<p style={{
|
||||
color: '#dc2626',
|
||||
fontSize: '14px',
|
||||
margin: '0'
|
||||
}}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSigningUp && signUp()}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#ffffff',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Minimum 6 characters"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSigningUp && signUp()}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#ffffff',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Re-enter your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSigningUp && signUp()}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#ffffff',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sign Up Button */}
|
||||
<button
|
||||
onClick={signUp}
|
||||
disabled={isSigningUp || !email || !password || !confirmPassword}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 16px',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#059669',
|
||||
color: '#ffffff',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: isSigningUp || !email || !password || !confirmPassword ? 'not-allowed' : 'pointer',
|
||||
opacity: isSigningUp || !email || !password || !confirmPassword ? 0.6 : 1,
|
||||
marginBottom: '20px'
|
||||
}}
|
||||
>
|
||||
{isSigningUp ? 'Creating Account...' : 'Create Account'}
|
||||
</button>
|
||||
|
||||
{/* Sign In Link */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
disabled={isSigningUp}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#3b82f6',
|
||||
fontSize: '14px',
|
||||
cursor: isSigningUp ? 'not-allowed' : 'pointer',
|
||||
textDecoration: 'underline',
|
||||
opacity: isSigningUp ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Already have an account? Sign in
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '24px',
|
||||
paddingTop: '20px',
|
||||
borderTop: '1px solid #e5e7eb'
|
||||
}}>
|
||||
<p style={{
|
||||
color: '#9ca3af',
|
||||
fontSize: '12px',
|
||||
margin: '0'
|
||||
}}>
|
||||
By creating an account, you agree to our terms of service
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user