mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-22 00:31:21 +00:00
feat(auth): add CAS-based SSO login support
Add backend CAS callback endpoints (redirect → validate ticket → establish session), frontend SSO login button, and runtime config plumbing. All SSO server URLs are externalized via SsoProperties with zero company-specific defaults. Also fix a pre-existing bug in web/docker-entrypoint.d/30-runtime-config.sh where auth-related runtime config env vars were not being substituted into runtime-config.js.
This commit is contained in:
parent
098616dcb6
commit
eb8dc031e7
16 changed files with 403 additions and 2 deletions
|
|
@ -79,6 +79,18 @@ SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_SCOPE=openid,profile,email
|
|||
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_NAME=OIDC
|
||||
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI=
|
||||
|
||||
# Optional: CAS-based SSO login (private deployments).
|
||||
# After enabling, redirect users to /api/v1/auth/sso/login to initiate login.
|
||||
# Register the application on the SSO admin console to obtain client-url and client-token.
|
||||
# The response fields default to "account", "id", "name" — override if your SSO uses different JSON keys.
|
||||
SKILLHUB_AUTH_SSO_ENABLED=false
|
||||
SKILLHUB_AUTH_SSO_BASE_URL=
|
||||
SKILLHUB_AUTH_SSO_VALIDATE_PATH=
|
||||
SKILLHUB_AUTH_SSO_CLIENT_URL=
|
||||
SKILLHUB_AUTH_SSO_CLIENT_TOKEN=
|
||||
# Frontend runtime config: shows the "Enterprise SSO Login" button when enabled.
|
||||
SKILLHUB_WEB_AUTH_SSO_ENABLED=false
|
||||
|
||||
# SMTP configuration for password reset verification emails.
|
||||
SPRING_MAIL_HOST=
|
||||
SPRING_MAIL_PORT=587
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import com.iflytek.skillhub.auth.config.SsoProperties;
|
||||
import com.iflytek.skillhub.auth.session.PlatformSessionService;
|
||||
import com.iflytek.skillhub.auth.sso.SsoClient;
|
||||
import com.iflytek.skillhub.auth.sso.SsoIdentityService;
|
||||
import com.iflytek.skillhub.auth.sso.SsoUser;
|
||||
import com.iflytek.skillhub.auth.sso.TicketValidationException;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* CAS-based SSO login controller.
|
||||
*
|
||||
* <p>Handles the redirect to the SSO server and the ticket-callback exchange
|
||||
* that establishes a platform session on success.
|
||||
*/
|
||||
@Controller
|
||||
@RequestMapping("/api/v1/auth/sso")
|
||||
public class SsoLoginController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SsoLoginController.class);
|
||||
|
||||
private final SsoProperties properties;
|
||||
private final SsoClient ssoClient;
|
||||
private final SsoIdentityService ssoIdentityService;
|
||||
private final PlatformSessionService platformSessionService;
|
||||
|
||||
public SsoLoginController(SsoProperties properties,
|
||||
SsoClient ssoClient,
|
||||
SsoIdentityService ssoIdentityService,
|
||||
PlatformSessionService platformSessionService) {
|
||||
this.properties = properties;
|
||||
this.ssoClient = ssoClient;
|
||||
this.ssoIdentityService = ssoIdentityService;
|
||||
this.platformSessionService = platformSessionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates SSO login by redirecting the browser to the SSO login page.
|
||||
*/
|
||||
@GetMapping("/login")
|
||||
public void ssoLogin(HttpServletResponse response) throws IOException {
|
||||
if (!properties.isEnabled()) {
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "SSO login is disabled");
|
||||
return;
|
||||
}
|
||||
String ssoLoginUrl = UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl())
|
||||
.path("/login")
|
||||
.queryParam("clientUrl", properties.getClientUrl())
|
||||
.build()
|
||||
.toUriString();
|
||||
response.sendRedirect(ssoLoginUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives the CAS ticket callback from the SSO server, validates the
|
||||
* ticket, establishes a platform session, and redirects the browser to the
|
||||
* frontend home page.
|
||||
*/
|
||||
@GetMapping("/callback")
|
||||
public void ssoCallback(@RequestParam("ticket") String ticket,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) throws IOException {
|
||||
if (!properties.isEnabled()) {
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "SSO login is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
SsoUser ssoUser = ssoClient.validateTicket(ticket);
|
||||
var principal = ssoIdentityService.resolveOrCreate(ssoUser);
|
||||
platformSessionService.establishSession(principal, request);
|
||||
response.sendRedirect("/");
|
||||
} catch (TicketValidationException e) {
|
||||
log.warn("SSO ticket validation failed: {}", e.getMessage());
|
||||
response.sendRedirect("/login?error=sso_auth_failed");
|
||||
} catch (Exception e) {
|
||||
log.error("SSO callback error", e);
|
||||
response.sendRedirect("/login?error=sso_error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package com.iflytek.skillhub.auth.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "skillhub.auth.sso")
|
||||
public class SsoProperties {
|
||||
|
||||
/** Kept disabled in OSS by default. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/** SSO server base URL. */
|
||||
private String baseUrl;
|
||||
|
||||
/** Ticket validation endpoint path on SSO server. */
|
||||
private String validatePath;
|
||||
|
||||
/** Client URL registered in SSO (used as callback base). */
|
||||
private String clientUrl;
|
||||
|
||||
/** Client token registered in SSO, used for logout API calls. */
|
||||
private String clientToken;
|
||||
|
||||
/** Response field mapping for SSO user info JSON. */
|
||||
private ResponseFields response = new ResponseFields();
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
|
||||
public String getBaseUrl() { return baseUrl; }
|
||||
public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
|
||||
|
||||
public String getValidatePath() { return validatePath; }
|
||||
public void setValidatePath(String validatePath) { this.validatePath = validatePath; }
|
||||
|
||||
public String getClientUrl() { return clientUrl; }
|
||||
public void setClientUrl(String clientUrl) { this.clientUrl = clientUrl; }
|
||||
|
||||
public String getClientToken() { return clientToken; }
|
||||
public void setClientToken(String clientToken) { this.clientToken = clientToken; }
|
||||
|
||||
public ResponseFields getResponse() { return response; }
|
||||
public void setResponse(ResponseFields response) { this.response = response; }
|
||||
|
||||
public static class ResponseFields {
|
||||
private String accountField = "account";
|
||||
private String idField = "id";
|
||||
private String nameField = "name";
|
||||
|
||||
public String getAccountField() { return accountField; }
|
||||
public void setAccountField(String accountField) { this.accountField = accountField; }
|
||||
|
||||
public String getIdField() { return idField; }
|
||||
public void setIdField(String idField) { this.idField = idField; }
|
||||
|
||||
public String getNameField() { return nameField; }
|
||||
public void setNameField(String nameField) { this.nameField = nameField; }
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ public class RouteSecurityPolicyRegistry {
|
|||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/direct/login"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/local/**"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/device/**"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/sso/**"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/check"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/actuator/health"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/v3/api-docs/**"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
package com.iflytek.skillhub.auth.sso;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import com.iflytek.skillhub.auth.config.SsoProperties;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* Client that validates a CAS service-ticket against the SSO server and
|
||||
* returns the associated user identity.
|
||||
*/
|
||||
@Service
|
||||
public class SsoClient {
|
||||
|
||||
private final SsoProperties properties;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public SsoClient(SsoProperties properties, RestTemplateBuilder restTemplateBuilder) {
|
||||
this.properties = properties;
|
||||
this.restTemplate = restTemplateBuilder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a CAS service ticket and returns the resolved user identity,
|
||||
* or throws {@link TicketValidationException} when the ticket is invalid
|
||||
* or the SSO server is unreachable.
|
||||
*/
|
||||
public SsoUser validateTicket(String ticket) {
|
||||
var request = Map.of(
|
||||
"Ticket", ticket,
|
||||
"Url", properties.getClientUrl()
|
||||
);
|
||||
var validateUrl = UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl())
|
||||
.path(properties.getValidatePath())
|
||||
.build()
|
||||
.toUriString();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> response = restTemplate.postForObject(validateUrl, request, Map.class);
|
||||
if (response == null || response.isEmpty()) {
|
||||
throw new TicketValidationException("Empty response from ticket validation");
|
||||
}
|
||||
|
||||
var fields = properties.getResponse();
|
||||
Object account = response.get(fields.getAccountField());
|
||||
Object id = response.get(fields.getIdField());
|
||||
Object name = response.get(fields.getNameField());
|
||||
|
||||
if (account == null || id == null) {
|
||||
throw new TicketValidationException(
|
||||
"Ticket validation response missing required fields: "
|
||||
+ fields.getAccountField() + ", " + fields.getIdField());
|
||||
}
|
||||
|
||||
return new SsoUser(
|
||||
String.valueOf(account),
|
||||
String.valueOf(id),
|
||||
name != null ? String.valueOf(name) : String.valueOf(account)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package com.iflytek.skillhub.auth.sso;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import com.iflytek.skillhub.auth.entity.IdentityBinding;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults;
|
||||
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Resolves SSO identities to platform users, creating or updating bindings and
|
||||
* user records as needed.
|
||||
*/
|
||||
@Service
|
||||
public class SsoIdentityService {
|
||||
|
||||
private static final String PROVIDER_CODE = "sso";
|
||||
|
||||
private final IdentityBindingRepository bindingRepo;
|
||||
private final UserAccountRepository userRepo;
|
||||
private final UserRoleBindingRepository roleBindingRepo;
|
||||
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
|
||||
public SsoIdentityService(IdentityBindingRepository bindingRepo,
|
||||
UserAccountRepository userRepo,
|
||||
UserRoleBindingRepository roleBindingRepo,
|
||||
GlobalNamespaceMembershipService globalNamespaceMembershipService) {
|
||||
this.bindingRepo = bindingRepo;
|
||||
this.userRepo = userRepo;
|
||||
this.roleBindingRepo = roleBindingRepo;
|
||||
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up or auto-creates a platform user for the given SSO identity and
|
||||
* returns the corresponding {@link PlatformPrincipal}.
|
||||
*/
|
||||
@Transactional
|
||||
public PlatformPrincipal resolveOrCreate(SsoUser ssoUser) {
|
||||
IdentityBinding binding = bindingRepo
|
||||
.findByProviderCodeAndSubject(PROVIDER_CODE, ssoUser.account())
|
||||
.orElse(null);
|
||||
|
||||
UserAccount user;
|
||||
if (binding != null) {
|
||||
user = userRepo.findById(binding.getUserId())
|
||||
.orElseThrow(() -> new IllegalStateException("User not found for binding"));
|
||||
user.setDisplayName(ssoUser.name());
|
||||
user = userRepo.save(user);
|
||||
} else {
|
||||
user = new UserAccount(
|
||||
"usr_" + UUID.randomUUID(),
|
||||
ssoUser.name(),
|
||||
null,
|
||||
null
|
||||
);
|
||||
user.setStatus(UserStatus.ACTIVE);
|
||||
user = userRepo.save(user);
|
||||
|
||||
globalNamespaceMembershipService.ensureMember(user.getId());
|
||||
|
||||
binding = new IdentityBinding(user.getId(), PROVIDER_CODE,
|
||||
ssoUser.account(), ssoUser.account());
|
||||
bindingRepo.save(binding);
|
||||
}
|
||||
|
||||
if (user.getStatus() != UserStatus.ACTIVE) {
|
||||
throw new IllegalStateException("User account is not active: " + user.getStatus());
|
||||
}
|
||||
|
||||
Set<String> roles = roleBindingRepo.findByUserId(user.getId()).stream()
|
||||
.map(rb -> rb.getRole().getCode())
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
roles = PlatformRoleDefaults.withDefaultUserRole(roles);
|
||||
|
||||
return new PlatformPrincipal(
|
||||
user.getId(), user.getDisplayName(), user.getEmail(),
|
||||
user.getAvatarUrl(), PROVIDER_CODE, roles
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.iflytek.skillhub.auth.sso;
|
||||
|
||||
/**
|
||||
* Carrier for user-info returned by the SSO ticket-validation endpoint.
|
||||
*
|
||||
* @param account unique domain account / username
|
||||
* @param id employee or user identifier
|
||||
* @param name display name
|
||||
*/
|
||||
public record SsoUser(String account, String id, String name) {
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.iflytek.skillhub.auth.sso;
|
||||
|
||||
/**
|
||||
* Thrown when the SSO ticket-validation endpoint rejects a ticket or returns
|
||||
* an unexpected response.
|
||||
*/
|
||||
public class TicketValidationException extends RuntimeException {
|
||||
|
||||
public TicketValidationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public TicketValidationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,9 +3,15 @@ set -eu
|
|||
|
||||
: "${SKILLHUB_WEB_API_BASE_URL:=}"
|
||||
: "${SKILLHUB_PUBLIC_BASE_URL:=}"
|
||||
: "${SKILLHUB_WEB_AUTH_DIRECT_ENABLED:=}"
|
||||
: "${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER:=}"
|
||||
: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED:=}"
|
||||
: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER:=}"
|
||||
: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO:=}"
|
||||
: "${SKILLHUB_WEB_AUTH_SSO_ENABLED:=}"
|
||||
|
||||
# Generate runtime-config.js
|
||||
envsubst '${SKILLHUB_WEB_API_BASE_URL} ${SKILLHUB_PUBLIC_BASE_URL}' \
|
||||
envsubst '${SKILLHUB_WEB_API_BASE_URL} ${SKILLHUB_PUBLIC_BASE_URL} ${SKILLHUB_WEB_AUTH_DIRECT_ENABLED} ${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO} ${SKILLHUB_WEB_AUTH_SSO_ENABLED}' \
|
||||
< /usr/share/nginx/html/runtime-config.js.template \
|
||||
> /usr/share/nginx/html/runtime-config.js
|
||||
|
||||
|
|
|
|||
|
|
@ -5,5 +5,6 @@ window.__SKILLHUB_RUNTIME_CONFIG__ = {
|
|||
authDirectProvider: "${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER}",
|
||||
authSessionBootstrapEnabled: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED}",
|
||||
authSessionBootstrapProvider: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER}",
|
||||
authSessionBootstrapAuto: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO}"
|
||||
authSessionBootstrapAuto: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO}",
|
||||
authSsoEnabled: "${SKILLHUB_WEB_AUTH_SSO_ENABLED}"
|
||||
};
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ type RuntimeConfig = {
|
|||
authSessionBootstrapEnabled?: string
|
||||
authSessionBootstrapProvider?: string
|
||||
authSessionBootstrapAuto?: string
|
||||
authSsoEnabled?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
@ -163,6 +164,16 @@ export function getSessionBootstrapRuntimeConfig(): SessionBootstrapRuntimeConfi
|
|||
}
|
||||
}
|
||||
|
||||
export type SsoRuntimeConfig = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function getSsoRuntimeConfig(): SsoRuntimeConfig {
|
||||
return {
|
||||
enabled: parseBooleanFlag(getRuntimeConfig().authSsoEnabled),
|
||||
}
|
||||
}
|
||||
|
||||
type ApiEnvelope<T> = {
|
||||
code: number
|
||||
msg: string
|
||||
|
|
|
|||
29
web/src/features/auth/sso-login-entry.tsx
Normal file
29
web/src/features/auth/sso-login-entry.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { getSsoRuntimeConfig } from '@/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
/**
|
||||
* Optional login entry that redirects the browser to the enterprise SSO login
|
||||
* page, which follows the CAS protocol to authenticate and redirect back.
|
||||
*/
|
||||
export function SsoLoginEntry() {
|
||||
const { t } = useTranslation()
|
||||
const config = getSsoRuntimeConfig()
|
||||
|
||||
if (!config.enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = '/api/v1/auth/sso/login'
|
||||
}}
|
||||
>
|
||||
{t('login.ssoLogin')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -224,6 +224,7 @@
|
|||
"enterpriseSsoAutoHint": "This deployment has automatic {{name}} probing enabled. If it does not succeed, you can continue with the standard login methods.",
|
||||
"enterpriseSsoAction": "Try {{name}}",
|
||||
"enterpriseSsoSubmitting": "Trying {{name}}...",
|
||||
"ssoLogin": "Enterprise SSO Login",
|
||||
"agreementPrefix": "By logging in, you agree to our",
|
||||
"terms": "Terms of Service",
|
||||
"and": "and",
|
||||
|
|
|
|||
|
|
@ -224,6 +224,7 @@
|
|||
"enterpriseSsoAutoHint": "当前部署已启用自动 {{name}} 探测。若未成功,你仍可继续使用现有登录方式。",
|
||||
"enterpriseSsoAction": "尝试 {{name}} 登录",
|
||||
"enterpriseSsoSubmitting": "正在尝试 {{name}} 登录...",
|
||||
"ssoLogin": "企业 SSO 登录",
|
||||
"agreementPrefix": "登录即表示你同意我们的",
|
||||
"terms": "服务条款",
|
||||
"and": "和",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ vi.mock('lucide-react', () => ({
|
|||
|
||||
vi.mock('@/api/client', () => ({
|
||||
getDirectAuthRuntimeConfig: () => ({ enabled: false }),
|
||||
getSsoRuntimeConfig: () => ({ enabled: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/login-button', () => ({
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Eye, EyeOff } from 'lucide-react'
|
|||
import { getDirectAuthRuntimeConfig } from '@/api/client'
|
||||
import { LoginButton } from '@/features/auth/login-button'
|
||||
import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry'
|
||||
import { SsoLoginEntry } from '@/features/auth/sso-login-entry'
|
||||
import { useAuthMethods } from '@/features/auth/use-auth-methods'
|
||||
import { usePasswordLogin } from '@/features/auth/use-password-login'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
|
@ -88,6 +89,8 @@ export function LoginPage() {
|
|||
onAuthenticated={() => navigate({ to: returnTo })}
|
||||
/>
|
||||
|
||||
<SsoLoginEntry />
|
||||
|
||||
<Tabs defaultValue="password" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="password">{t('login.tabPassword')}</TabsTrigger>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue