This commit is contained in:
Anthony Stirling 2025-10-30 23:40:47 +00:00
parent 7205ba9c9a
commit 6d98b0b209
13 changed files with 628 additions and 235 deletions

View File

@ -43,26 +43,45 @@ public class JarPathUtil {
}
/**
* Gets the path to the restart-helper.jar file Expected to be in the same directory as the main
* JAR
* Gets the path to the restart-helper.jar file. Checks multiple possible locations: 1. Same
* directory as the main JAR (production deployment) 2. ./build/libs/restart-helper.jar
* (development build) 3. app/common/build/libs/restart-helper.jar (multi-module build)
*
* @return Path to restart-helper.jar, or null if not found
*/
public static Path restartHelperJar() {
Path appJar = currentJar();
if (appJar == null) {
return null;
// Define possible locations to check (in order of preference)
Path[] possibleLocations = new Path[4];
// Location 1: Same directory as main JAR (production)
if (appJar != null) {
possibleLocations[0] = appJar.getParent().resolve("restart-helper.jar");
}
Path helperJar = appJar.getParent().resolve("restart-helper.jar");
// Location 2: ./build/libs/ (development build)
possibleLocations[1] = Paths.get("build", "libs", "restart-helper.jar").toAbsolutePath();
if (Files.isRegularFile(helperJar)) {
log.debug("Restart helper JAR located at: {}", helperJar);
return helperJar;
} else {
log.warn("Restart helper JAR not found at: {}", helperJar);
return null;
// Location 3: app/common/build/libs/ (multi-module build)
possibleLocations[2] =
Paths.get("app", "common", "build", "libs", "restart-helper.jar").toAbsolutePath();
// Location 4: Current working directory
possibleLocations[3] = Paths.get("restart-helper.jar").toAbsolutePath();
// Check each location
for (Path location : possibleLocations) {
if (location != null && Files.isRegularFile(location)) {
log.info("Restart helper JAR found at: {}", location);
return location;
} else if (location != null) {
log.debug("Restart helper JAR not found at: {}", location);
}
}
log.warn("Restart helper JAR not found in any expected location");
return null;
}
/**

View File

@ -10,6 +10,7 @@ import java.util.Arrays;
import java.util.Deque;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
@ -135,6 +136,17 @@ public class YamlHelper {
} else if ("true".equals(newValue) || "false".equals(newValue)) {
newValueNode =
new ScalarNode(Tag.BOOL, String.valueOf(newValue), ScalarStyle.PLAIN);
} else if (newValue instanceof Map<?, ?> map) {
// Handle Map objects - convert to MappingNode
List<NodeTuple> mapTuples = new ArrayList<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
ScalarNode mapKeyNode =
new ScalarNode(
Tag.STR, String.valueOf(entry.getKey()), ScalarStyle.PLAIN);
Node mapValueNode = convertValueToNode(entry.getValue());
mapTuples.add(new NodeTuple(mapKeyNode, mapValueNode));
}
newValueNode = new MappingNode(Tag.MAP, mapTuples, FlowStyle.BLOCK);
} else if (newValue instanceof List<?> list) {
List<Node> sequenceNodes = new ArrayList<>();
for (Object item : list) {
@ -458,6 +470,43 @@ public class YamlHelper {
return isInteger(object) || isShort(object) || isByte(object) || isLong(object);
}
/**
* Converts a Java value to a YAML Node.
*
* @param value The value to convert.
* @return The corresponding YAML Node.
*/
private Node convertValueToNode(Object value) {
if (value == null) {
return new ScalarNode(Tag.NULL, "null", ScalarStyle.PLAIN);
} else if (isAnyInteger(value)) {
return new ScalarNode(Tag.INT, String.valueOf(value), ScalarStyle.PLAIN);
} else if (isFloat(value)) {
Object floatValue = Float.valueOf(String.valueOf(value));
return new ScalarNode(Tag.FLOAT, String.valueOf(floatValue), ScalarStyle.PLAIN);
} else if (value instanceof Boolean || "true".equals(value) || "false".equals(value)) {
return new ScalarNode(Tag.BOOL, String.valueOf(value), ScalarStyle.PLAIN);
} else if (value instanceof Map<?, ?> map) {
// Recursively handle nested maps
List<NodeTuple> mapTuples = new ArrayList<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
ScalarNode mapKeyNode =
new ScalarNode(Tag.STR, String.valueOf(entry.getKey()), ScalarStyle.PLAIN);
Node mapValueNode = convertValueToNode(entry.getValue());
mapTuples.add(new NodeTuple(mapKeyNode, mapValueNode));
}
return new MappingNode(Tag.MAP, mapTuples, FlowStyle.BLOCK);
} else if (value instanceof List<?> list) {
List<Node> sequenceNodes = new ArrayList<>();
for (Object item : list) {
sequenceNodes.add(convertValueToNode(item));
}
return new SequenceNode(Tag.SEQ, sequenceNodes, FlowStyle.FLOW);
} else {
return new ScalarNode(Tag.STR, String.valueOf(value), ScalarStyle.PLAIN);
}
}
/**
* Copies comments from an old node to a new one.
*

View File

@ -17,11 +17,13 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@ExtendWith(MockitoExtension.class)
class LicenseKeyCheckerTest {
@Mock private KeygenLicenseVerifier verifier;
@Mock private UserLicenseSettingsService userLicenseSettingsService;
@Test
void premiumDisabled_skipsVerification() {
@ -29,7 +31,9 @@ class LicenseKeyCheckerTest {
props.getPremium().setEnabled(false);
props.getPremium().setKey("dummy");
LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props);
LicenseKeyChecker checker =
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
checker.init();
assertEquals(License.NORMAL, checker.getPremiumLicenseEnabledResult());
verifyNoInteractions(verifier);
@ -42,7 +46,9 @@ class LicenseKeyCheckerTest {
props.getPremium().setKey("abc");
when(verifier.verifyLicense("abc")).thenReturn(License.PRO);
LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props);
LicenseKeyChecker checker =
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
checker.init();
assertEquals(License.PRO, checker.getPremiumLicenseEnabledResult());
verify(verifier).verifyLicense("abc");
@ -58,7 +64,9 @@ class LicenseKeyCheckerTest {
props.getPremium().setKey("file:" + file.toString());
when(verifier.verifyLicense("filekey")).thenReturn(License.ENTERPRISE);
LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props);
LicenseKeyChecker checker =
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
checker.init();
assertEquals(License.ENTERPRISE, checker.getPremiumLicenseEnabledResult());
verify(verifier).verifyLicense("filekey");
@ -71,7 +79,9 @@ class LicenseKeyCheckerTest {
props.getPremium().setEnabled(true);
props.getPremium().setKey("file:" + file.toString());
LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props);
LicenseKeyChecker checker =
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
checker.init();
assertEquals(License.NORMAL, checker.getPremiumLicenseEnabledResult());
verifyNoInteractions(verifier);

View File

@ -67,32 +67,35 @@ export default function AdminSecuritySection() {
const premiumData = premiumResponse.data || {};
const systemData = systemResponse.data || {};
console.log('[AdminSecuritySection] Raw backend data:');
console.log('Security:', JSON.parse(JSON.stringify(securityData)));
console.log('Premium:', JSON.parse(JSON.stringify(premiumData)));
console.log('System:', JSON.parse(JSON.stringify(systemData)));
const { _pending: securityPending, ...securityActive } = securityData;
const { _pending: premiumPending, ...premiumActive } = premiumData;
const { _pending: systemPending, ...systemActive } = systemData;
console.log('[AdminSecuritySection] Extracted pending blocks:', {
securityPending: JSON.parse(JSON.stringify(securityPending || {})),
premiumPending: JSON.parse(JSON.stringify(premiumPending || {})),
systemPending: JSON.parse(JSON.stringify(systemPending || {}))
});
const combined: any = {
...securityActive,
audit: premiumActive.enterpriseFeatures?.audit || {
enabled: false,
level: 2,
retentionDays: 90
},
html: systemActive.html || {
urlSecurity: {
enabled: true,
level: 'MEDIUM',
allowedDomains: [],
blockedDomains: [],
internalTlds: ['.local', '.internal', '.corp', '.home'],
blockPrivateNetworks: true,
blockLocalhost: true,
blockLinkLocal: true,
blockCloudMetadata: true
}
}
...securityActive
};
// Only add audit if it exists (don't create defaults)
if (premiumActive.enterpriseFeatures?.audit) {
combined.audit = premiumActive.enterpriseFeatures.audit;
}
// Only add html if it exists (don't create defaults)
if (systemActive.html) {
combined.html = systemActive.html;
}
// Merge all _pending blocks
const mergedPending: any = {};
if (securityPending) {
@ -115,11 +118,25 @@ export default function AdminSecuritySection() {
const { audit, html, ...securitySettings } = settings;
const deltaSettings: Record<string, any> = {
// Security settings
'security.enableLogin': securitySettings.enableLogin,
'security.csrfDisabled': securitySettings.csrfDisabled,
'security.loginMethod': securitySettings.loginMethod,
'security.loginAttemptCount': securitySettings.loginAttemptCount,
'security.loginResetTimeMinutes': securitySettings.loginResetTimeMinutes,
// JWT settings
'security.jwt.persistence': securitySettings.jwt?.persistence,
'security.jwt.enableKeyRotation': securitySettings.jwt?.enableKeyRotation,
'security.jwt.enableKeyCleanup': securitySettings.jwt?.enableKeyCleanup,
'security.jwt.keyRetentionDays': securitySettings.jwt?.keyRetentionDays,
'security.jwt.secureCookie': securitySettings.jwt?.secureCookie,
// Premium audit settings
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
'premium.enterpriseFeatures.audit.level': audit?.level,
'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays
};
// System HTML settings
if (html?.urlSecurity) {
deltaSettings['system.html.urlSecurity.enabled'] = html.urlSecurity.enabled;
deltaSettings['system.html.urlSecurity.level'] = html.urlSecurity.level;
@ -133,7 +150,7 @@ export default function AdminSecuritySection() {
}
return {
sectionData: securitySettings,
sectionData: {},
deltaSettings
};
}
@ -217,7 +234,12 @@ export default function AdminSecuritySection() {
<div>
<NumberInput
label={t('admin.settings.security.loginAttemptCount', 'Login Attempt Limit')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.loginAttemptCount', 'Login Attempt Limit')}</span>
<PendingBadge show={isFieldPending('loginAttemptCount')} />
</Group>
}
description={t('admin.settings.security.loginAttemptCount.description', 'Maximum number of failed login attempts before account lockout')}
value={settings.loginAttemptCount || 0}
onChange={(value) => setSettings({ ...settings, loginAttemptCount: Number(value) })}
@ -228,7 +250,12 @@ export default function AdminSecuritySection() {
<div>
<NumberInput
label={t('admin.settings.security.loginResetTimeMinutes', 'Login Reset Time (minutes)')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.loginResetTimeMinutes', 'Login Reset Time (minutes)')}</span>
<PendingBadge show={isFieldPending('loginResetTimeMinutes')} />
</Group>
}
description={t('admin.settings.security.loginResetTimeMinutes.description', 'Time before failed login attempts are reset')}
value={settings.loginResetTimeMinutes || 0}
onChange={(value) => setSettings({ ...settings, loginResetTimeMinutes: Number(value) })}
@ -295,10 +322,13 @@ export default function AdminSecuritySection() {
{t('admin.settings.security.jwt.enableKeyRotation.description', 'Automatically rotate JWT signing keys for improved security')}
</Text>
</div>
<Switch
checked={settings.jwt?.enableKeyRotation || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyRotation: e.target.checked } })}
/>
<Group gap="xs">
<Switch
checked={settings.jwt?.enableKeyRotation || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyRotation: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('jwt.enableKeyRotation')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@ -308,15 +338,23 @@ export default function AdminSecuritySection() {
{t('admin.settings.security.jwt.enableKeyCleanup.description', 'Automatically remove old JWT keys after retention period')}
</Text>
</div>
<Switch
checked={settings.jwt?.enableKeyCleanup || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyCleanup: e.target.checked } })}
/>
<Group gap="xs">
<Switch
checked={settings.jwt?.enableKeyCleanup || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyCleanup: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('jwt.enableKeyCleanup')} />
</Group>
</div>
<div>
<NumberInput
label={t('admin.settings.security.jwt.keyRetentionDays', 'Key Retention Days')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.jwt.keyRetentionDays', 'Key Retention Days')}</span>
<PendingBadge show={isFieldPending('jwt.keyRetentionDays')} />
</Group>
}
description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')}
value={settings.jwt?.keyRetentionDays || 7}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings.jwt, keyRetentionDays: Number(value) } })}
@ -369,7 +407,12 @@ export default function AdminSecuritySection() {
<div>
<NumberInput
label={t('admin.settings.security.audit.level', 'Audit Level')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.audit.level', 'Audit Level')}</span>
<PendingBadge show={isFieldPending('audit.level')} />
</Group>
}
description={t('admin.settings.security.audit.level.description', '0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE')}
value={settings.audit?.level || 2}
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, level: Number(value) } })}
@ -380,7 +423,12 @@ export default function AdminSecuritySection() {
<div>
<NumberInput
label={t('admin.settings.security.audit.retentionDays', 'Audit Retention (days)')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.audit.retentionDays', 'Audit Retention (days)')}</span>
<PendingBadge show={isFieldPending('audit.retentionDays')} />
</Group>
}
description={t('admin.settings.security.audit.retentionDays.description', 'Number of days to retain audit logs')}
value={settings.audit?.retentionDays || 90}
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, retentionDays: Number(value) } })}
@ -425,7 +473,12 @@ export default function AdminSecuritySection() {
<div>
<Select
label={t('admin.settings.security.htmlUrlSecurity.level', 'Security Level')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.level', 'Security Level')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.level')} />
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.level.description', 'MAX: whitelist only, MEDIUM: block internal networks, OFF: no restrictions')}
value={settings.html?.urlSecurity?.level || 'MEDIUM'}
onChange={(value) => setSettings({
@ -452,7 +505,12 @@ export default function AdminSecuritySection() {
{/* Allowed Domains */}
<div>
<Textarea
label={t('admin.settings.security.htmlUrlSecurity.allowedDomains', 'Allowed Domains (Whitelist)')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.allowedDomains', 'Allowed Domains (Whitelist)')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.allowedDomains')} />
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.allowedDomains.description', 'One domain per line (e.g., cdn.example.com). Only these domains allowed when level is MAX')}
value={settings.html?.urlSecurity?.allowedDomains?.join('\n') || ''}
onChange={(e) => setSettings({
@ -474,7 +532,12 @@ export default function AdminSecuritySection() {
{/* Blocked Domains */}
<div>
<Textarea
label={t('admin.settings.security.htmlUrlSecurity.blockedDomains', 'Blocked Domains (Blacklist)')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.blockedDomains', 'Blocked Domains (Blacklist)')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.blockedDomains')} />
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.blockedDomains.description', 'One domain per line (e.g., malicious.com). Additional domains to block')}
value={settings.html?.urlSecurity?.blockedDomains?.join('\n') || ''}
onChange={(e) => setSettings({
@ -496,7 +559,12 @@ export default function AdminSecuritySection() {
{/* Internal TLDs */}
<div>
<Textarea
label={t('admin.settings.security.htmlUrlSecurity.internalTlds', 'Internal TLDs')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.internalTlds', 'Internal TLDs')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.internalTlds')} />
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.internalTlds.description', 'One TLD per line (e.g., .local, .internal). Block domains with these TLD patterns')}
value={settings.html?.urlSecurity?.internalTlds?.join('\n') || ''}
onChange={(e) => setSettings({
@ -525,16 +593,19 @@ export default function AdminSecuritySection() {
{t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.description', 'Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)')}
</Text>
</div>
<Switch
checked={settings.html?.urlSecurity?.blockPrivateNetworks || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
}
})}
/>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockPrivateNetworks || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
}
})}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockPrivateNetworks')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@ -544,16 +615,19 @@ export default function AdminSecuritySection() {
{t('admin.settings.security.htmlUrlSecurity.blockLocalhost.description', 'Block localhost and loopback addresses (127.x.x.x, ::1)')}
</Text>
</div>
<Switch
checked={settings.html?.urlSecurity?.blockLocalhost || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLocalhost: e.target.checked }
}
})}
/>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockLocalhost || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLocalhost: e.target.checked }
}
})}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockLocalhost')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@ -563,16 +637,19 @@ export default function AdminSecuritySection() {
{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal.description', 'Block link-local addresses (169.254.x.x, fe80::/10)')}
</Text>
</div>
<Switch
checked={settings.html?.urlSecurity?.blockLinkLocal || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLinkLocal: e.target.checked }
}
})}
/>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockLinkLocal || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLinkLocal: e.target.checked }
}
})}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockLinkLocal')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@ -582,16 +659,19 @@ export default function AdminSecuritySection() {
{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata.description', 'Block cloud provider metadata endpoints (169.254.169.254)')}
</Text>
</div>
<Switch
checked={settings.html?.urlSecurity?.blockCloudMetadata || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockCloudMetadata: e.target.checked }
}
})}
/>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockCloudMetadata || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockCloudMetadata: e.target.checked }
}
})}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockCloudMetadata')} />
</Group>
</div>
</Stack>
</Accordion.Panel>

View File

@ -16,6 +16,7 @@ import {
import { DateInput } from '@mantine/dates';
import { useTranslation } from 'react-i18next';
import auditService, { AuditEvent, AuditFilters } from '@app/services/auditService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface AuditEventsTableProps {}
@ -115,6 +116,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = () => {
onChange={(value) => handleFilterChange('eventType', value || undefined)}
clearable
style={{ flex: 1, minWidth: 200 }}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
placeholder={t('audit.events.filterByUser', 'Filter by user')}
@ -124,6 +126,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = () => {
clearable
searchable
style={{ flex: 1, minWidth: 200 }}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<DateInput
placeholder={t('audit.events.startDate', 'Start date')}
@ -133,6 +136,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = () => {
}
clearable
style={{ flex: 1, minWidth: 150 }}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<DateInput
placeholder={t('audit.events.endDate', 'End date')}
@ -142,6 +146,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = () => {
}
clearable
style={{ flex: 1, minWidth: 150 }}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button variant="outline" onClick={handleClearFilters}>
{t('audit.events.clearFilters', 'Clear')}
@ -255,6 +260,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = () => {
onClose={() => setSelectedEvent(null)}
title={t('audit.events.eventDetails', 'Event Details')}
size="lg"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{selectedEvent && (
<Stack gap="md">

View File

@ -12,6 +12,7 @@ import { DateInput } from '@mantine/dates';
import { useTranslation } from 'react-i18next';
import auditService, { AuditFilters } from '@app/services/auditService';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface AuditExportSectionProps {}
@ -126,6 +127,7 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = () => {
onChange={(value) => handleFilterChange('eventType', value || undefined)}
clearable
style={{ flex: 1, minWidth: 200 }}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
placeholder={t('audit.export.filterByUser', 'Filter by user')}
@ -135,6 +137,7 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = () => {
clearable
searchable
style={{ flex: 1, minWidth: 200 }}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
</Group>
<Group>
@ -146,6 +149,7 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = () => {
}
clearable
style={{ flex: 1, minWidth: 200 }}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<DateInput
placeholder={t('audit.export.endDate', 'End date')}
@ -155,6 +159,7 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = () => {
}
clearable
style={{ flex: 1, minWidth: 200 }}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button variant="outline" onClick={handleClearFilters}>
{t('audit.export.clearFilters', 'Clear')}

View File

@ -1,6 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { alert } from '@app/components/toast';
import apiClient from '@app/services/apiClient';
export function useRestartServer() {
const { t } = useTranslation();
@ -27,18 +28,12 @@ export function useRestartServer() {
),
});
const response = await fetch('/api/v1/admin/settings/restart', {
method: 'POST',
});
await apiClient.post('/api/v1/admin/settings/restart');
if (response.ok) {
// Wait a moment then reload the page
setTimeout(() => {
window.location.reload();
}, 3000);
} else {
throw new Error('Failed to restart');
}
// Wait a moment then reload the page
setTimeout(() => {
window.location.reload();
}, 3000);
} catch (_error) {
alert({
alertType: 'error',

View File

@ -71,15 +71,15 @@ export function useAdminSettings<T = any>(
// Store raw settings (includes _pending if present)
setRawSettings(rawData);
// Extract active settings (without _pending) for delta comparison
const { _pending, ...activeOnly } = rawData;
setOriginalSettings(activeOnly as T);
console.log(`[useAdminSettings:${sectionName}] Original active settings:`, JSON.stringify(activeOnly, null, 2));
// Merge pending changes into settings for display
const mergedSettings = mergePendingSettings(rawData);
console.log(`[useAdminSettings:${sectionName}] Merged settings:`, JSON.stringify(mergedSettings, null, 2));
// Store merged settings as original for delta comparison
// This ensures we compare against what the user SAW (with pending), not raw active values
setOriginalSettings(mergedSettings as T);
console.log(`[useAdminSettings:${sectionName}] Original settings (for comparison):`, JSON.stringify(mergedSettings, null, 2));
setSettings(mergedSettings as T);
} catch (error) {
console.error(`[useAdminSettings:${sectionName}] Failed to fetch:`, error);
@ -106,15 +106,46 @@ export function useAdminSettings<T = any>(
// Use custom save logic for complex sections
const { sectionData, deltaSettings } = saveTransformer(settings);
// Save section data (with delta applied)
const sectionDelta = computeDelta(originalSettings, sectionData);
// Get original sectionData using same transformer for fair comparison
const { sectionData: originalSectionData } = saveTransformer(originalSettings);
// Save section data (with delta applied) - compare transformed vs transformed
const sectionDelta = computeDelta(originalSectionData, sectionData);
if (Object.keys(sectionDelta).length > 0) {
await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, sectionDelta);
}
// Save delta settings if provided
// Save delta settings if provided (filter to only changed values)
if (deltaSettings && Object.keys(deltaSettings).length > 0) {
await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
// Build deltaSettings from original using same transformer to get correct structure
const { deltaSettings: originalDeltaSettings } = saveTransformer(originalSettings);
console.log(`[useAdminSettings:${sectionName}] Comparing deltaSettings:`, {
original: originalDeltaSettings,
current: deltaSettings
});
// Compare current vs original deltaSettings (both have same backend paths)
const changedDeltaSettings: Record<string, any> = {};
for (const [key, value] of Object.entries(deltaSettings)) {
const originalValue = originalDeltaSettings?.[key];
// Only include if value actually changed
if (JSON.stringify(value) !== JSON.stringify(originalValue)) {
changedDeltaSettings[key] = value;
console.log(`[useAdminSettings:${sectionName}] Delta field changed: ${key}`, {
original: originalValue,
new: value
});
}
}
if (Object.keys(changedDeltaSettings).length > 0) {
console.log(`[useAdminSettings:${sectionName}] Sending delta settings:`, changedDeltaSettings);
await apiClient.put('/api/v1/admin/settings', { settings: changedDeltaSettings });
} else {
console.log(`[useAdminSettings:${sectionName}] No delta settings changed, skipping`);
}
}
} else {
// Simple single-endpoint save with delta

View File

@ -68,6 +68,35 @@ export interface InviteUsersResponse {
error?: string;
}
export interface InviteLinkRequest {
email: string;
role: string;
teamId?: number;
expiryHours?: number;
sendEmail?: boolean;
}
export interface InviteLinkResponse {
token: string;
inviteUrl: string;
email: string;
expiresAt: string;
expiryHours: number;
emailSent?: boolean;
emailError?: string;
error?: string;
}
export interface InviteToken {
id: number;
email: string;
role: string;
teamId?: number;
createdBy: string;
createdAt: string;
expiresAt: string;
}
/**
* User Management Service
* Provides functions to interact with user management backend APIs
@ -169,4 +198,60 @@ export const userManagementService = {
return response.data;
},
/**
* Generate an invite link (admin only)
*/
async generateInviteLink(data: InviteLinkRequest): Promise<InviteLinkResponse> {
const formData = new FormData();
// Only append email if it's provided and not empty
if (data.email && data.email.trim()) {
formData.append('email', data.email);
}
formData.append('role', data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
}
if (data.expiryHours) {
formData.append('expiryHours', data.expiryHours.toString());
}
if (data.sendEmail !== undefined) {
formData.append('sendEmail', data.sendEmail.toString());
}
const response = await apiClient.post<InviteLinkResponse>(
'/api/v1/invite/generate',
formData,
{
suppressErrorToast: true,
} as any
);
return response.data;
},
/**
* Get list of active invite links (admin only)
*/
async getInviteLinks(): Promise<InviteToken[]> {
const response = await apiClient.get<{ invites: InviteToken[] }>('/api/v1/invite/list');
return response.data.invites;
},
/**
* Revoke an invite link (admin only)
*/
async revokeInviteLink(inviteId: number): Promise<void> {
await apiClient.delete(`/api/v1/invite/revoke/${inviteId}`, {
suppressErrorToast: true,
} as any);
},
/**
* Clean up expired invite links (admin only)
*/
async cleanupExpiredInvites(): Promise<{ deletedCount: number }> {
const response = await apiClient.post<{ deletedCount: number }>('/api/v1/invite/cleanup');
return response.data;
},
};

View File

@ -50,12 +50,19 @@ export function isFieldPending<T extends SettingsWithPending>(
fieldPath: string
): boolean {
if (!settings?._pending) {
console.log(`[isFieldPending] No _pending block found for field: ${fieldPath}`);
return false;
}
// Navigate the pending object using dot notation
const value = getNestedValue(settings._pending, fieldPath);
return value !== undefined;
const isPending = value !== undefined;
if (isPending) {
console.log(`[isFieldPending] Field ${fieldPath} IS pending with value:`, value);
}
return isPending;
}
/**

View File

@ -6,6 +6,7 @@ import Landing from "@app/routes/Landing";
import Login from "@app/routes/Login";
import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@proprietary/routes/InviteAccept";
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
// Import global styles
@ -26,6 +27,7 @@ export default function App() {
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />

View File

@ -19,6 +19,8 @@ import {
SegmentedControl,
Tooltip,
CloseButton,
Avatar,
Box,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
@ -38,7 +40,8 @@ export default function PeopleSection() {
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<'email' | 'direct'>('direct');
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(null);
// License information
const [licenseInfo, setLicenseInfo] = useState<{
@ -66,6 +69,15 @@ export default function PeopleSection() {
teamId: undefined as number | undefined,
});
// Form state for invite link
const [inviteLinkForm, setInviteLinkForm] = useState({
email: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
expiryHours: 72,
sendEmail: false,
});
// Form state for edit user modal
const [editForm, setEditForm] = useState({
role: 'ROLE_USER',
@ -209,6 +221,44 @@ export default function PeopleSection() {
}
};
const handleGenerateInviteLink = async () => {
try {
setProcessing(true);
const response = await userManagementService.generateInviteLink({
email: inviteLinkForm.email,
role: inviteLinkForm.role,
teamId: inviteLinkForm.teamId,
expiryHours: inviteLinkForm.expiryHours,
sendEmail: inviteLinkForm.sendEmail,
});
// Construct full frontend URL
const frontendUrl = `${window.location.origin}/invite/${response.token}`;
setGeneratedInviteLink(frontendUrl);
if (response.emailSent) {
alert({
alertType: 'success',
title: t('workspace.people.inviteLink.successWithEmail', 'Invite link generated and email sent!')
});
} else {
alert({
alertType: 'success',
title: t('workspace.people.inviteLink.success', 'Invite link generated successfully!')
});
}
} catch (error: any) {
console.error('Failed to generate invite link:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.inviteLink.error', 'Failed to generate invite link');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleUpdateUserRole = async () => {
if (!selectedUser) return;
@ -287,6 +337,18 @@ export default function PeopleSection() {
});
};
const closeInviteModal = () => {
setInviteModalOpened(false);
setGeneratedInviteLink(null);
setInviteLinkForm({
email: '',
role: 'ROLE_USER',
teamId: undefined,
expiryHours: 72,
sendEmail: false,
});
};
const filteredUsers = users.filter((user) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase())
);
@ -309,12 +371,12 @@ export default function PeopleSection() {
const renderRoleOption = ({ option }: { option: any }) => (
<Group gap="sm" wrap="nowrap">
<LocalIcon icon={option.icon} width="1.25rem" height="1.25rem" style={{ flexShrink: 0 }} />
<div style={{ flex: 1 }}>
<Box style={{ flex: 1 }}>
<Text size="sm" fw={500}>{option.label}</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
{option.description}
</Text>
</div>
</Box>
</Group>
);
@ -444,37 +506,30 @@ export default function PeopleSection() {
>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
backgroundColor: user.enabled
? 'var(--mantine-color-blue-1)'
: 'var(--mantine-color-gray-2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
fontSize: '0.875rem',
color: user.enabled
? 'var(--mantine-color-blue-7)'
: 'var(--mantine-color-gray-6)',
flexShrink: 0,
border: user.isActive ? '2px solid var(--mantine-color-green-6)' : 'none',
opacity: user.enabled ? 1 : 0.5,
}}
title={
<Tooltip
label={
!user.enabled
? t('workspace.people.disabled', 'Disabled')
: user.isActive
? t('workspace.people.activeSession', 'Active session')
: t('workspace.people.active', 'Active')
}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{user.username.charAt(0).toUpperCase()}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<Avatar
size={32}
color={user.enabled ? 'blue' : 'gray'}
styles={{
root: {
border: user.isActive ? '2px solid var(--mantine-color-green-6)' : 'none',
opacity: user.enabled ? 1 : 0.5,
}
}}
>
{user.username.charAt(0).toUpperCase()}
</Avatar>
</Tooltip>
<Box style={{ minWidth: 0, flex: 1 }}>
<Tooltip label={user.username} disabled={user.username.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<Text
size="sm"
@ -496,7 +551,7 @@ export default function PeopleSection() {
{user.email}
</Text>
)}
</div>
</Box>
</Group>
</Table.Td>
<Table.Td w={100}>
@ -587,16 +642,16 @@ export default function PeopleSection() {
{/* Add Member Modal */}
<Modal
opened={inviteModalOpened}
onClose={() => setInviteModalOpened(false)}
onClose={closeInviteModal}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<Box pos="relative">
<CloseButton
onClick={() => setInviteModalOpened(false)}
onClick={closeInviteModal}
size="lg"
style={{
position: 'absolute',
@ -656,12 +711,19 @@ export default function PeopleSection() {
<div>
<SegmentedControl
value={inviteMode}
onChange={(value) => setInviteMode(value as 'email' | 'direct')}
onChange={(value) => {
setInviteMode(value as 'email' | 'direct' | 'link');
setGeneratedInviteLink(null);
}}
data={[
{
label: t('workspace.people.inviteMode.username', 'Username'),
value: 'direct',
},
{
label: t('workspace.people.inviteMode.link', 'Link'),
value: 'link',
},
{
label: t('workspace.people.inviteMode.email', 'Email'),
value: 'email',
@ -673,6 +735,90 @@ export default function PeopleSection() {
</div>
</Tooltip>
{/* Link Mode */}
{inviteMode === 'link' && (
<>
<TextInput
label={t('workspace.people.inviteLink.email', 'Email (optional)')}
placeholder={t('workspace.people.inviteLink.emailPlaceholder', 'user@example.com')}
value={inviteLinkForm.email}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, email: e.currentTarget.value })}
description={t('workspace.people.inviteLink.emailDescription', 'If provided, the link will be tied to this email address')}
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteLinkForm.role}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteLinkForm.teamId?.toString()}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<TextInput
label={t('workspace.people.inviteLink.expiryHours', 'Link expires in (hours)')}
type="number"
value={inviteLinkForm.expiryHours}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })}
min={1}
max={720}
/>
{inviteLinkForm.email && (
<Checkbox
label={t('workspace.people.inviteLink.sendEmail', 'Send invite link via email')}
description={t('workspace.people.inviteLink.sendEmailDescription', 'Also send the link to the provided email address')}
checked={inviteLinkForm.sendEmail}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })}
/>
)}
{/* Display generated link */}
{generatedInviteLink && (
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
<Stack gap="sm">
<Text size="sm" fw={500}>{t('workspace.people.inviteLink.generated', 'Invite Link Generated')}</Text>
<Group gap="xs">
<TextInput
value={generatedInviteLink}
readOnly
style={{ flex: 1 }}
/>
<Button
variant="light"
onClick={async () => {
try {
await navigator.clipboard.writeText(generatedInviteLink);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
} catch {
// Fallback for browsers without clipboard API
const textArea = document.createElement('textarea');
textArea.value = generatedInviteLink;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
}
}}
>
<LocalIcon icon="content-copy" width="1rem" height="1rem" />
</Button>
</Group>
</Stack>
</Paper>
)}
</>
)}
{/* Email Mode */}
{inviteMode === 'email' && config?.enableEmailInvites && (
<>
@ -749,7 +895,7 @@ export default function PeopleSection() {
{/* Action Button */}
<Button
onClick={inviteMode === 'email' ? handleEmailInvite : handleInviteUser}
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
loading={processing}
fullWidth
size="md"
@ -757,10 +903,12 @@ export default function PeopleSection() {
>
{inviteMode === 'email'
? t('workspace.people.emailInvite.submit', 'Send Invites')
: t('workspace.people.addMember.submit')}
: inviteMode === 'link'
? t('workspace.people.inviteLink.submit', 'Generate Link')
: t('workspace.people.addMember.submit')}
</Button>
</Stack>
</div>
</Box>
</Modal>
{/* Edit User Modal */}
@ -773,7 +921,7 @@ export default function PeopleSection() {
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<Box pos="relative">
<CloseButton
onClick={closeEditModal}
size="lg"
@ -816,7 +964,7 @@ export default function PeopleSection() {
{t('workspace.people.editMember.submit')}
</Button>
</Stack>
</div>
</Box>
</Modal>
</Stack>
);

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Stack, Text, Paper, Center, Loader, Button, TextInput, PasswordInput, Anchor } from '@mantine/core';
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
import AuthLayout from '@app/routes/authShared/AuthLayout';
import LoginHeader from '@app/routes/login/LoginHeader';
@ -16,10 +17,9 @@ interface InviteData {
}
export default function InviteAccept() {
const [searchParams] = useSearchParams();
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const token = searchParams.get('token');
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
@ -127,9 +127,9 @@ export default function InviteAccept() {
return (
<AuthLayout>
<LoginHeader title={t('invite.validating', 'Validating invitation...')} />
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
</div>
<Center py="xl">
<Loader size="md" />
</Center>
</AuthLayout>
);
}
@ -160,122 +160,78 @@ export default function InviteAccept() {
/>
{inviteData && !inviteData.emailRequired && (
<div style={{ marginBottom: '1.5rem' }}>
<div style={{
textAlign: 'center',
padding: '1.25rem',
backgroundColor: 'rgba(59, 130, 246, 0.08)',
borderRadius: '0.75rem',
border: '1px solid rgba(59, 130, 246, 0.2)'
}}>
<p style={{
fontSize: '0.8125rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: '#6b7280',
margin: '0 0 0.5rem 0',
fontWeight: 500
}}>
<Paper withBorder p="md" mb="lg" bg="blue.0" style={{ borderColor: 'var(--mantine-color-blue-3)' }}>
<Stack gap="xs" align="center">
<Text size="xs" tt="uppercase" c="dimmed" fw={500} style={{ letterSpacing: '0.05em' }}>
{t('invite.accountFor', 'Creating account for')}
</p>
<p style={{
fontSize: '1.125rem',
fontWeight: 600,
margin: '0 0 0.75rem 0',
color: '#1f2937'
}}>
</Text>
<Text size="lg" fw={600}>
{inviteData.email}
</p>
<p style={{
fontSize: '0.8125rem',
color: '#6b7280',
margin: 0
}}>
</Text>
<Text size="xs" c="dimmed">
{t('invite.linkExpires', 'Link expires')}: {new Date(inviteData.expiresAt).toLocaleDateString()} at {new Date(inviteData.expiresAt).toLocaleTimeString()}
</p>
</div>
</div>
</Text>
</Stack>
</Paper>
)}
<ErrorMessage error={error} />
<form onSubmit={handleAccept}>
{inviteData?.emailRequired && (
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email" className="auth-label">
{t('invite.email', 'Email address')}
</label>
<input
id="email"
<Stack gap="md">
{inviteData?.emailRequired && (
<TextInput
label={t('invite.email', 'Email address')}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('invite.emailPlaceholder', 'Enter your email address')}
disabled={submitting}
required
className="auth-input"
autoComplete="email"
/>
</div>
)}
)}
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="password" className="auth-label">
{t('invite.choosePassword', 'Choose a password')}
</label>
<input
id="password"
type="password"
<PasswordInput
label={t('invite.choosePassword', 'Choose a password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('invite.passwordPlaceholder', 'Enter your password')}
disabled={submitting}
required
className="auth-input"
autoComplete="new-password"
/>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label htmlFor="confirmPassword" className="auth-label">
{t('invite.confirmPassword', 'Confirm password')}
</label>
<input
id="confirmPassword"
type="password"
<PasswordInput
label={t('invite.confirmPassword', 'Confirm password')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t('invite.confirmPasswordPlaceholder', 'Re-enter your password')}
disabled={submitting}
required
className="auth-input"
autoComplete="new-password"
/>
</div>
<div className="auth-section">
<button
type="submit"
disabled={submitting}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{submitting ? t('invite.creating', 'Creating Account...') : t('invite.createAccount', 'Create Account')}
</button>
</div>
<div className="auth-section">
<button
type="submit"
disabled={submitting}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{submitting ? t('invite.creating', 'Creating Account...') : t('invite.createAccount', 'Create Account')}
</button>
</div>
</Stack>
</form>
<div style={{ textAlign: 'center', margin: '1rem 0 0' }}>
<p style={{ color: '#6b7280', fontSize: '0.875rem', margin: 0 }}>
<Center mt="md">
<Text size="sm" c="dimmed">
{t('invite.alreadyHaveAccount', 'Already have an account?')}{' '}
<button
type="button"
onClick={() => navigate('/login')}
className="auth-link-black"
>
<Anchor component="button" type="button" onClick={() => navigate('/login')} c="dark">
{t('invite.signIn', 'Sign in')}
</button>
</p>
</div>
</Anchor>
</Text>
</Center>
</AuthLayout>
);
}