Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,26 +1,74 @@
package com.learnSmart.learnSmart.Config;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.source.DefaultJWKSetCache;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.DefaultResourceRetriever;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.*;

import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.TimeUnit;

@Slf4j
@Configuration
public class JwtConfig {

private static final String SUPABASE_JWKS_URI =
"https://xwymzxvwnneooojzouxq.supabase.co/auth/v1/.well-known/jwks.json";
private static final String ISSUER =
"https://xwymzxvwnneooojzouxq.supabase.co/auth/v1";

@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder
.withJwkSetUri("https://xwymzxvwnneooojzouxq.supabase.co/auth/v1/.well-known/jwks.json")
.jwsAlgorithm(SignatureAlgorithm.ES256)
.build();

OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer("https://xwymzxvwnneooojzouxq.supabase.co/auth/v1")
public JwtDecoder jwtDecoder() throws Exception {
// --- 1. Local source: loaded once from classpath at startup, zero network cost ---
ClassPathResource jwksResource = new ClassPathResource("supabase-jwks.json");
JWKSet localJwkSet;
try (var is = jwksResource.getInputStream()) {
localJwkSet = JWKSet.parse(new String(is.readAllBytes(), StandardCharsets.UTF_8));
}
ImmutableJWKSet<SecurityContext> localSource = new ImmutableJWKSet<>(localJwkSet);
log.info("Loaded {} local JWK key(s) from classpath", localJwkSet.getKeys().size());

// --- 2. Remote source: cached, used only when local key lookup misses (key rotation) ---
JWKSource<SecurityContext> remoteSource = new RemoteJWKSet<>(

Check warning on line 48 in Backend/src/main/java/com/learnSmart/learnSmart/Config/JwtConfig.java

View check run for this annotation

SonarQubeCloud / [kyuhisan_learnsmart_backend] SonarCloud Code Analysis

Remove this use of "RemoteJWKSet"; it is deprecated.

See more on https://sonarcloud.io/project/issues?id=kyuhisan_learnsmart_backend&issues=AZ6S_zDf5nSEz7HZ85Z_&open=AZ6S_zDf5nSEz7HZ85Z_&pullRequest=162
new URL(SUPABASE_JWKS_URI),

Check warning on line 49 in Backend/src/main/java/com/learnSmart/learnSmart/Config/JwtConfig.java

View check run for this annotation

SonarQubeCloud / [kyuhisan_learnsmart_backend] SonarCloud Code Analysis

Remove this use of "URL"; it is deprecated.

See more on https://sonarcloud.io/project/issues?id=kyuhisan_learnsmart_backend&issues=AZ6S_zDf5nSEz7HZ85aA&open=AZ6S_zDf5nSEz7HZ85aA&pullRequest=162
new DefaultResourceRetriever(5_000, 10_000), // 5s connect, 10s read
new DefaultJWKSetCache(10L, 5L, TimeUnit.MINUTES) // cache 10 min, refresh after 5

Check warning on line 51 in Backend/src/main/java/com/learnSmart/learnSmart/Config/JwtConfig.java

View check run for this annotation

SonarQubeCloud / [kyuhisan_learnsmart_backend] SonarCloud Code Analysis

Remove this use of "DefaultJWKSetCache"; it is deprecated.

See more on https://sonarcloud.io/project/issues?id=kyuhisan_learnsmart_backend&issues=AZ6S_zDf5nSEz7HZ85aB&open=AZ6S_zDf5nSEz7HZ85aB&pullRequest=162
);
jwtDecoder.setJwtValidator(validator);
return jwtDecoder;

// --- 3. Combined source: local first, remote fallback on key miss ---
JWKSource<SecurityContext> combinedSource = (jwkSelector, context) -> {
List<JWK> keys = localSource.get(jwkSelector, context);
if (!keys.isEmpty()) return keys;
log.warn("Key not found in local JWK set — Supabase may have rotated keys. Falling back to remote JWKS.");
return remoteSource.get(jwkSelector, context);
};

// --- 4. Build decoder using the combined source ---
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
jwtProcessor.setJWSKeySelector(
new JWSVerificationKeySelector<>(JWSAlgorithm.ES256, combinedSource)
);

NimbusJwtDecoder decoder = new NimbusJwtDecoder(jwtProcessor);
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(ISSUER)
));
return decoder;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,4 @@ public CorsConfigurationSource corsConfigurationSource() {
source.registerCorsConfiguration("/**", config);
return source;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class QuizGeminiService {
private final String openAiApiKey;
private final RestTemplate restTemplate;

private static final String GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=";
private static final String GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=";
private static final String OPENAI_URL = "https://api.openai.com/v1/chat/completions";

public QuizGeminiService(GeminiService geminiService,
Expand Down
1 change: 1 addition & 0 deletions Backend/src/main/resources/supabase-jwks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"keys":[{"alg":"ES256","crv":"P-256","ext":true,"key_ops":["verify"],"kid":"edba6d66-522a-4c8e-9cc0-8e5ccf47267b","kty":"EC","use":"sig","x":"KbI3d64o5HRLIbxAxiGzk-Ubv8ESsTICCU4N7BU5ttk","y":"iGpq12-_iSr0rVY_aDCtv8u1tBheYt-Zd3Ou8oBWywk"}]}
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ export function ProfessorAIQuizBuilder() {
</Panel>

<Panel title="SELECT MODULE" accent={C.yellow} action={<Tag label="STEP 2" bg={C.yellow} />} overflow="visible">
<Dropdown value={module} options={modules} onChange={m => { setModule(m); setSelectedKviz(null) }} loading={loadingModules} placeholder="Select a module" />
<Dropdown value={module} options={modules} onChange={m => { setModule(m); setSelectedKviz(null); setQuestions(null); setSavedToBank(false) }} loading={loadingModules} placeholder="Select a module" />
</Panel>
</div>

Expand Down
62 changes: 37 additions & 25 deletions Frontend/src/features/aiQuizBuilder/QuizBuilderCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,23 +61,29 @@ export function BankaQuestionCard({ q, index, selected, onToggle, onDelete, dele
q: BankaQuestion; index: number; selected?: boolean
onToggle?: () => void; onDelete?: () => void; deleting?: boolean
}) {
const isMobile = useBreakpoint() === 'mobile'
return (
<div style={{ background: selected ? C.greenLt : C.paper, border: `${BW.base} solid ${selected ? C.green : C.ink}`, borderRadius: R.sm, boxShadow: mkShadow(), overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: S[2], padding: `${S[2]} ${S[3]}`, borderBottom: `${BW.base} solid ${C.ink}`, background: selected ? C.green : C.cream }}>
<Tag label={`Q${index + 1}`} bg={C.mutedLt} />
{q.tezavnost && <Tag label={q.tezavnost} bg={DIFFICULTY_COLOR[q.tezavnost as keyof typeof DIFFICULTY_COLOR] ?? C.mutedLt} />}
<span style={{ fontFamily: "'Archivo Black', sans-serif", fontSize: FS.sm, color: selected ? C.paper : C.ink, lineHeight: 1.4, flex: 1 }}>
{q.besediloVprasanja}
</span>
{onToggle && (
<ComicBtn sm color={selected ? C.paper : C.green} onClick={onToggle}>
{selected ? '✓ SELECTED' : '+ SELECT'}
</ComicBtn>
)}
{onDelete && (
<ComicBtn sm color={C.red} onClick={onDelete} disabled={deleting}>
{deleting ? '...' : '✕'}
</ComicBtn>
<div style={{ background: selected ? C.greenLt : C.paper, border: `${BW.base} solid ${selected ? C.green : C.ink}`, borderRadius: R.sm, boxShadow: mkShadow('base', selected ? C.green : C.ink), overflow: 'hidden' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: S[1], padding: `${S[2]} ${S[3]}`, borderBottom: `${BW.base} solid ${selected ? C.green : C.ink}`, background: selected ? C.green : C.cream }}>
<div style={{ display: 'flex', alignItems: 'center', gap: S[2] }}>
<Tag label={`Q${index + 1}`} bg={C.mutedLt} />
{q.tezavnost && <Tag label={q.tezavnost} bg={DIFFICULTY_COLOR[q.tezavnost as keyof typeof DIFFICULTY_COLOR] ?? C.mutedLt} />}
{!isMobile && <span style={{ fontFamily: "'Archivo Black', sans-serif", fontSize: FS.sm, color: selected ? C.paper : C.ink, lineHeight: 1.4, flex: 1 }}>{q.besediloVprasanja}</span>}
{onToggle && (
<ComicBtn sm color={selected ? C.paper : C.green} onClick={onToggle} style={{ marginLeft: isMobile ? 'auto' : undefined }}>
{selected ? '✓ SELECTED' : '+ SELECT'}
</ComicBtn>
)}
{onDelete && (
<ComicBtn sm color={C.red} onClick={onDelete} disabled={deleting} style={{ marginLeft: isMobile ? 'auto' : undefined }}>
{deleting ? '...' : '✕'}
</ComicBtn>
)}
</div>
{isMobile && (
<span style={{ fontFamily: "'Archivo Black', sans-serif", fontSize: FS.sm, color: selected ? C.paper : C.ink, lineHeight: 1.4 }}>
{q.besediloVprasanja}
</span>
)}
</div>
<QuestionOptions moznosti={q.moznosti} correct={q.indeksPravilnegaOdgovora} />
Expand All @@ -88,17 +94,23 @@ export function BankaQuestionCard({ q, index, selected, onToggle, onDelete, dele
export function KvizQuestionCard({ q, index, onRemove, removing }: {
q: KvizQuestion; index: number; onRemove: () => void; removing: boolean
}) {
const isMobile = useBreakpoint() === 'mobile'
return (
<div style={{ background: C.paper, border: `${BW.base} solid ${C.ink}`, borderRadius: R.sm, boxShadow: mkShadow(), overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: S[2], padding: `${S[2]} ${S[3]}`, borderBottom: `${BW.base} solid ${C.ink}`, background: C.cream }}>
<Tag label={`Q${index + 1}`} bg={C.mutedLt} />
{q.tezavnost && <Tag label={q.tezavnost} bg={DIFFICULTY_COLOR[q.tezavnost as keyof typeof DIFFICULTY_COLOR] ?? C.mutedLt} />}
<span style={{ fontFamily: "'Archivo Black', sans-serif", fontSize: FS.sm, color: C.ink, lineHeight: 1.4, flex: 1 }}>
{q.besediloVprasanja}
</span>
<ComicBtn sm color={C.orange} onClick={onRemove} disabled={removing}>
{removing ? '...' : '↩ REMOVE'}
</ComicBtn>
<div style={{ display: 'flex', flexDirection: 'column', gap: S[1], padding: `${S[2]} ${S[3]}`, borderBottom: `${BW.base} solid ${C.ink}`, background: C.cream }}>
<div style={{ display: 'flex', alignItems: 'center', gap: S[2] }}>
<Tag label={`Q${index + 1}`} bg={C.mutedLt} />
{q.tezavnost && <Tag label={q.tezavnost} bg={DIFFICULTY_COLOR[q.tezavnost as keyof typeof DIFFICULTY_COLOR] ?? C.mutedLt} />}
{!isMobile && <span style={{ fontFamily: "'Archivo Black', sans-serif", fontSize: FS.sm, color: C.ink, lineHeight: 1.4, flex: 1 }}>{q.besediloVprasanja}</span>}
<ComicBtn sm color={C.orange} onClick={onRemove} disabled={removing} style={{ marginLeft: isMobile ? 'auto' : undefined }}>
{removing ? '...' : '↩ REMOVE'}
</ComicBtn>
</div>
{isMobile && (
<span style={{ fontFamily: "'Archivo Black', sans-serif", fontSize: FS.sm, color: C.ink, lineHeight: 1.4 }}>
{q.besediloVprasanja}
</span>
)}
</div>
<QuestionOptions moznosti={q.moznosti} correct={q.indeksPravilnegaOdgovora} />
</div>
Expand Down
22 changes: 12 additions & 10 deletions Frontend/src/features/aiQuizBuilder/QuizBuilderViews.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function GenerateView({ isMobile, module, difficulty, setDifficulty, coun
const noTranscript = module !== null && !module.hasTranscript

return (
<div style={{ display: isMobile ? 'flex' : 'grid', flexDirection: 'column', gridTemplateColumns: '1fr 1.5fr', gap: S[4], alignItems: 'stretch' }}>
<div style={{ display: isMobile ? 'flex' : 'grid', flexDirection: 'column', ...(isMobile ? {} : { gridTemplateColumns: '1fr 1.5fr' }), gap: S[4], alignItems: 'stretch' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: S[3] }}>
<Panel title="GENERATE QUESTIONS" accent={C.yellow} p={S[4]} overflow="visible">
<div style={{ display: 'flex', flexDirection: 'column', gap: S[4] }}>
Expand Down Expand Up @@ -149,7 +149,7 @@ export function NewQuizView({ isMobile, banka, newKvizNaziv, setNewKvizNaziv, ne
creatingKviz: boolean; onCreate: () => void; onGoGenerate: () => void
}) {
return (
<div style={{ display: isMobile ? 'flex' : 'grid', flexDirection: 'column', gridTemplateColumns: '1fr 1.5fr', gap: S[4], alignItems: 'start' }}>
<div style={{ display: isMobile ? 'flex' : 'grid', flexDirection: 'column', ...(isMobile ? {} : { gridTemplateColumns: '1fr 1.5fr' }), gap: S[4], alignItems: isMobile ? 'stretch' : 'start' }}>
<Panel title="NEW QUIZ" accent={C.green} p={S[4]}>
<div style={{ display: 'flex', flexDirection: 'column', gap: S[4] }}>
<div>
Expand Down Expand Up @@ -219,7 +219,7 @@ export function KvizView({ isMobile, kvizi, loadingKvizi, selectedKviz, setSelec
</div>

{selectedKviz && (
<div style={{ display: isMobile ? 'flex' : 'grid', flexDirection: 'column', gridTemplateColumns: '1fr 1.5fr', gap: S[4], alignItems: 'start' }}>
<div style={{ display: isMobile ? 'flex' : 'grid', flexDirection: 'column', ...(isMobile ? {} : { gridTemplateColumns: '1fr 1.5fr' }), gap: S[4], alignItems: isMobile ? 'stretch' : 'start' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: S[3] }}>
<Panel title="QUIZ INFO" accent={C.orange} p={S[4]}>
<div style={{ display: 'flex', flexDirection: 'column', gap: S[2] }}>
Expand Down Expand Up @@ -249,13 +249,15 @@ export function KvizView({ isMobile, kvizi, loadingKvizi, selectedKviz, setSelec
) : banka.length === 0 ? (
<div style={{ padding: S[3], textAlign: 'center', color: C.muted, fontSize: FS.xs }}>Bank is empty</div>
) : banka.map((q, i) => (
<div key={q.id} style={{ display: 'flex', alignItems: 'center', gap: S[2], padding: `${S[2]} ${S[3]}`, background: C.paper, border: `${BW.base} solid ${C.ink}`, borderRadius: R.sm, boxShadow: mkShadow() }}>
<span style={{ fontFamily: "'Archivo Black', sans-serif", fontSize: FS.xs, color: C.muted, flexShrink: 0 }}>Q{i + 1}</span>
{q.tezavnost && <Tag label={q.tezavnost} bg={DIFFICULTY_COLOR[q.tezavnost as keyof typeof DIFFICULTY_COLOR] ?? C.mutedLt} />}
<span style={{ fontSize: FS.xs, color: C.ink, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{q.besediloVprasanja}</span>
<ComicBtn sm color={C.green} onClick={() => onAdd(q.id)} disabled={addingToKvizId === q.id}>
{addingToKvizId === q.id ? '...' : '+ ADD'}
</ComicBtn>
<div key={q.id} style={{ display: 'flex', flexDirection: 'column', gap: S[1], padding: `${S[2]} ${S[3]}`, background: C.paper, border: `${BW.base} solid ${C.ink}`, borderRadius: R.sm, boxShadow: mkShadow() }}>
<div style={{ display: 'flex', alignItems: 'center', gap: S[2] }}>
<span style={{ fontFamily: "'Archivo Black', sans-serif", fontSize: FS.xs, color: C.muted, flexShrink: 0 }}>Q{i + 1}</span>
{q.tezavnost && <Tag label={q.tezavnost} bg={DIFFICULTY_COLOR[q.tezavnost as keyof typeof DIFFICULTY_COLOR] ?? C.mutedLt} />}
<ComicBtn sm color={C.green} onClick={() => onAdd(q.id)} disabled={addingToKvizId === q.id} style={{ marginLeft: 'auto' }}>
{addingToKvizId === q.id ? '...' : '+ ADD'}
</ComicBtn>
</div>
<span style={{ fontSize: FS.xs, color: C.ink }}>{q.besediloVprasanja}</span>
</div>
))}
</div>
Expand Down
Loading
Loading