Compare commits

...
5 Commits
Author SHA1 Message Date
Anthony Stirling 24cabb7070 email signup 2025-08-18 10:22:21 +01:00
Anthony Stirling a07f2cbe05 testing 2025-08-15 16:32:11 +01:00
Anthony Stirling 03a311e78d add linkedin 2025-08-15 14:53:49 +01:00
Anthony Stirling 2de89fb173 added other auths for fun 2025-08-15 14:40:48 +01:00
Anthony Stirling db815802f7 loginTest 2025-08-15 12:33:49 +01:00
21 changed files with 3527 additions and 366 deletions
@@ -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
+2
View File
@@ -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'
@@ -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();
}
}
@@ -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();
}
}
}
@@ -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);
}
}
@@ -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 {
@@ -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);
@@ -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
+113 -4
View File
@@ -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"
+1
View File
@@ -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
View File
@@ -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>
);
}
+171
View File
@@ -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
+58
View File
@@ -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()
}
+187
View File
@@ -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
}
+186
View File
@@ -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>
)
}
+800
View File
@@ -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>
)
}
+430
View File
@@ -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>
)
}
+794
View File
@@ -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>
)
}
+87
View File
@@ -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>
)
}
+301
View File
@@ -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>
)
}