Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
02ddb4e
feat(frontend): redirect to callback url after login
lajczi Mar 1, 2026
bd8769d
chore: apply suggestion from code review
lajczi Mar 2, 2026
81a8122
chore: fix Norbiros skill issue
lajczi Mar 2, 2026
601beb3
feat(backend): add callback for registration & email confirmation
lajczi Mar 2, 2026
cb2a82f
chore: regenerate `openapi.json` & update `AuthForm`
lajczi Mar 2, 2026
dd68d40
chore(backend): add max length validation to `callback`
lajczi Mar 2, 2026
a140e36
feat: add callback url to `submit_personal_info` page
lajczi Mar 3, 2026
a87cbcb
Merge branch 'master' into issue/455
lajczi Apr 19, 2026
ff3c932
test(backend): add additional test coverage for callback url
lajczi Apr 19, 2026
eb1e5f8
refactor(backend): merge email_confirmation_without_callback into ema…
claude Apr 21, 2026
0504071
fix: add validation message and safe callback query cast
lajczi Apr 21, 2026
55fb245
...
lajczi Apr 21, 2026
8729fec
one last place
lajczi Apr 21, 2026
1bfa0cd
Merge origin/master into issue/455 and resolve auth callback conflicts
lajczi May 31, 2026
9993644
lol
lajczi May 31, 2026
c11bf36
revert: undo accidental TasksTemplate submodule update
lajczi May 31, 2026
6690366
chore: regen openapi and simplify auth confirmation test
lajczi May 31, 2026
e35ea7e
revert: drop unintended auth test changes
lajczi May 31, 2026
3699375
Merge branch 'master' into issue/455
lajczi May 31, 2026
baefdda
regen openapi schema
lajczi May 31, 2026
dc95e21
Merge branch 'Hack4Krak:master' into issue/455
lajczi Jul 15, 2026
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
11 changes: 9 additions & 2 deletions backend/src/models/email_verification_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ use uuid::Uuid;
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case", tag = "name", content = "data")]
pub enum EmailVerificationAction {
ConfirmEmailAddress { user_information: UserInformation },
ConfirmEmailAddress {
user_information: UserInformation,
#[serde(default)]
callback: Option<String>,
},
ResetPassword,
RegisterTeam { organization: String },
}
Expand Down Expand Up @@ -122,6 +126,7 @@ mod tests {
name: NAME.to_string(),
..Default::default()
},
callback: None,
};
let (name, data) = action.get();
assert_eq!(name, "confirm_email_address");
Expand All @@ -135,6 +140,7 @@ mod tests {
name: NAME.to_string(),
..Default::default()
},
callback: Some("/panel".to_string()),
};
let (action_type, additional_data) = action.get();

Expand All @@ -147,8 +153,9 @@ mod tests {
let restored = model.get_action().unwrap();

match restored {
EmailVerificationAction::ConfirmEmailAddress { user_information } => {
EmailVerificationAction::ConfirmEmailAddress { user_information, callback } => {
assert_eq!(NAME, user_information.name);
assert_eq!(callback, Some("/panel".to_string()));
}
_ => panic!("Unexpected variant"),
}
Expand Down
8 changes: 6 additions & 2 deletions backend/src/routes/auth/confirm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ pub async fn confirm_email(
confirmation_code: web::Path<Uuid>,
) -> Result<HttpResponse, Error> {
match AuthenticationService::confirm_email(&app_state, confirmation_code.into_inner()).await {
Ok(()) => {
let url = EnvConfig::get()
Ok(callback) => {
let mut url = EnvConfig::get()
.frontend_url
.join("/login?redirect_from_confirmation=true")?;

if let Some(callback) = callback {
url.query_pairs_mut().append_pair("callback", &callback);
}

let mut response = common_responses::create_redirect_response(url)?;

Ok(response.body("Email successfully confirmed. Redirecting..."))
Expand Down
3 changes: 3 additions & 0 deletions backend/src/routes/auth/register.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::models::user::validate_name_chars;
use crate::services::authentication::AuthenticationService;
use crate::utils::app_state;
use crate::utils::error::Error;
use crate::utils::validation::validate_callback;
use actix_web::web::Json;
use actix_web::{HttpResponse, post, web};
use actix_web_validation::Validated;
Expand All @@ -20,6 +21,8 @@ pub struct RegisterModel {
pub email: String,
#[validate(length(min = 8, max = 32))]
pub password: Password,
#[validate(length(max = 256), custom(function = "validate_callback"))]
pub callback: Option<String>,
}

#[utoipa::path(
Expand Down
15 changes: 10 additions & 5 deletions backend/src/services/authentication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ impl AuthenticationService {

let confirmation_code = email_verification_request::Model::create(
&app_state.database,
EmailVerificationAction::ConfirmEmailAddress { user_information },
EmailVerificationAction::ConfirmEmailAddress {
user_information,
callback: credentials.callback.clone(),
},
credentials.email.clone(),
Some(Duration::minutes(30)),
)
Expand Down Expand Up @@ -119,14 +122,16 @@ impl AuthenticationService {
pub async fn confirm_email(
app_state: &app_state::AppState,
confirmation_code: Uuid,
) -> Result<(), Error> {
) -> Result<Option<String>, Error> {
let email_confirmation = email_verification_request::Model::find_and_verify(
&app_state.database,
confirmation_code,
)
.await?;
let EmailVerificationAction::ConfirmEmailAddress { user_information } =
email_confirmation.get_action()?
let EmailVerificationAction::ConfirmEmailAddress {
user_information,
callback,
} = email_confirmation.get_action()?
else {
return Err(Error::InvalidEmailConfirmationCode);
};
Expand All @@ -135,7 +140,7 @@ impl AuthenticationService {

email_confirmation.delete(&app_state.database).await?;

Ok(())
Ok(callback)
}

fn create_email_confirmation_link(confirmation_code: &str) -> Result<String, Error> {
Expand Down
1 change: 1 addition & 0 deletions backend/src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ pub mod qr_code;
pub mod real_ip;
pub mod sse_event;
pub mod success_response;
pub mod validation;
9 changes: 9 additions & 0 deletions backend/src/utils/validation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
use validator::ValidationError;

pub fn validate_callback(callback: &str) -> Result<(), ValidationError> {
if callback.starts_with('/') {
return Ok(());
}
Err(ValidationError::new("invalid_callback")
.with_message("Callback URL must start with '/'".into()))
}
121 changes: 120 additions & 1 deletion backend/tests/routes/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ async fn email_confirmation_success() {
use sea_orm::EntityTrait;

let test_database = TestDatabase::new().await;

let email_confirmation = test_database
.with_email_verification_request(UpdatableModel::default())
.await;
Expand Down Expand Up @@ -241,3 +240,123 @@ async fn reset_password_flow() {
let response = test::call_service(&app, request).await;
assert_eq!(response.status(), 200);
}

#[actix_web::test]
async fn email_confirmation_with_callback() {
use chrono::Utc;
use hack4krak_backend::entities::email_verification_request;
use sea_orm::{EntityTrait, Set};
use uuid::Uuid;

let test_database = TestDatabase::new().await;

let confirmation_code = Uuid::new_v4();
let email_confirmation = email_verification_request::ActiveModel {
id: Set(confirmation_code),
email: Set("".to_string()),
action_type: Set("confirm_email_address".to_string()),
additional_data: Set(Some(json!({
"user_information": {
"name": "test_user",
"email": "example@gmail.com",
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$nTzWdmrtGEOnwCocrg76xg$yv16FfDT5+meKwPmSiV+MF9kP8Man6bXZs+BloFTKIk"
},
"callback": "/tasks"
}))),
expiration_time: Set(Some(Utc::now().naive_utc() + chrono::Duration::minutes(30))),
created_at: Set(Utc::now().naive_utc()),
};
email_verification_request::Entity::insert(email_confirmation)
.exec(&test_database.database)
.await
.unwrap();

let app = TestApp::default()
.with_database(test_database)
.build_app()
.await;

let path = format!("/auth/confirm/{confirmation_code}");
let request = test::TestRequest::get().uri(&path).to_request();
let response = test::call_service(&app, request).await;
assert!(response.status().is_success());

let refresh_header = response
.headers()
.get("Refresh")
.unwrap()
.to_str()
.unwrap()
.to_string();
assert!(
refresh_header.contains("callback=%2Ftasks"),
"Redirect should contain callback parameter, got: {refresh_header}"
);
}

#[cfg(feature = "full-test-suite")]
#[actix_web::test]
async fn register_with_callback_persists_to_confirmation() {
use crate::test_utils::mail::SmtpTestClient;

let test_database = TestDatabase::new().await;
let smtp_client = SmtpTestClient::new().await;
let app = TestApp::default()
.with_database(test_database)
.with_smtp_client(smtp_client.smtp_client.clone())
.build_app()
.await;

let request = test::TestRequest::post()
.uri("/auth/register")
.set_json(json!({
"email": "test@example.com",
"name": "test_user",
"first_name": "Test",
"password": "password123",
"callback": "/panel/tasks"
}))
.to_request();
let response = test::call_service(&app, request).await;
assert!(response.status().is_success());

let confirmation_code = smtp_client.find_uuid_in_first_email().await;
let request = test::TestRequest::get()
.uri(&format!("/auth/confirm/{confirmation_code}"))
.to_request();
let response = test::call_service(&app, request).await;
assert!(response.status().is_success());

let refresh_header = response
.headers()
.get("Refresh")
.unwrap()
.to_str()
.unwrap()
.to_string();
assert!(
refresh_header.contains("callback=%2Fpanel%2Ftasks"),
"Callback should persist from registration to confirmation redirect, got: {refresh_header}"
);
}

#[actix_web::test]
async fn register_with_invalid_callback_rejected() {
let app = TestApp::default().build_app().await;

let request = test::TestRequest::post()
.uri("/auth/register")
.set_json(json!({
"email": "test@example.com",
"name": "test_user",
"first_name": "Test",
"password": "password123",
"callback": "https://evil.com"
}))
.to_request();
let response = test::call_service(&app, request).await;
assert!(
response.status().is_client_error(),
"Callback with absolute URL should be rejected"
);
}
16 changes: 12 additions & 4 deletions frontend/app/components/AuthForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const { proxy } = useScriptUmamiAnalytics()
const OAuthBaseUrl = `${useRuntimeConfig().public.openFetch.api.baseURL}/auth/oauth`

const route = useRoute()
const callback = computed(() => {
const value = route.query.callback?.toString()
return value?.startsWith('/') ? value : undefined
})

if (route.query.redirect_from_confirmation === 'true' && import.meta.client) {
toast.add({
Expand All @@ -50,24 +54,28 @@ async function onSubmit(event: Schema) {
toast.add({ title: 'Oczekiwanie', description: 'Wysyłanie emaila…', color: 'info' })
}

const body = props.isLogin
? event
: { ...event, callback: callback.value }

await useNuxtApp().$api(address, {
method: 'POST',
credentials: 'include',
body: event,
body,
})

if (props.isLogin) {
proxy.track('account_login_success', {
method: 'email',
})
toast.add({ title: 'Sukces', description: 'Pomyślnie zalogowano!', color: 'success' })
await navigateTo('/panel/event')
await navigateTo(callback.value || '/panel/event')
} else {
proxy.track('account_registration_success', {
method: 'email',
})
toast.add({ title: 'Sukces', description: 'Pomyślnie zarejestrowano! Wysłaliśmy Ci na podany adres email link do aktywacji konta', color: 'success' })
await navigateTo('/login')
await navigateTo({ path: '/login', query: callback.value ? { callback: callback.value } : undefined })
}
} catch (error) {
proxy.track(props.isLogin ? 'account_login_error' : 'account_registration_error', {
Expand Down Expand Up @@ -108,7 +116,7 @@ function trackOAuth(provider: 'google' | 'github') {
<div class="flex flex-col gap-1 w-full text-center">
<span class="text-sm text-neutral-400">
{{ isLogin ? 'Nie masz konta?' : 'Masz już konto?' }}
<NuxtLink class="link" :to="isLogin ? '/register' : '/login'">
<NuxtLink class="link" :to="{ path: isLogin ? '/register' : '/login', query: callback ? { callback } : undefined }">
{{ isLogin ? 'Załóż je' : 'Zaloguj się' }}
</NuxtLink>
</span>
Expand Down
10 changes: 7 additions & 3 deletions frontend/app/middleware/auth.global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,24 @@ export default defineNuxtRouteMiddleware(async (to) => {
redirect: 'error',
})
if (error.value || !data.value) {
return '/login'
return await navigateTo({ name: 'login', query: { callback: to.fullPath } })
}
if (to.path.startsWith('/panel')) {
if (data.value.has_completed_onboarding === false) {
return '/account/onboarding'
return await navigateTo({ path: '/account/onboarding', query: { callback: to.fullPath } })
}
}
if (to.path === '/account/onboarding') {
if (data.value.has_completed_onboarding === true) {
const callback = to.query.callback?.toString()
if (callback?.startsWith('/')) {
return callback
}
return '/panel'
}
}
} catch {
return '/login'
return await navigateTo({ name: 'login', query: { callback: to.fullPath } })
}
}
})
5 changes: 3 additions & 2 deletions frontend/app/middleware/guest.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
export default defineNuxtRouteMiddleware(async () => {
export default defineNuxtRouteMiddleware(async (to) => {
try {
const user = await useAuth('/account/', {
redirect: 'error',
})
if (!user.error.value && user.data.value) {
return '/account'
const callback = to.query.callback?.toString()
return callback?.startsWith('/') ? callback : '/account'
}
} catch (error) {
console.error(error)
Expand Down
7 changes: 6 additions & 1 deletion frontend/app/pages/account/onboarding.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ const STEP_TITLES = ['Powitanie', 'Pytania profilujące', 'Jak nas znalazłeś?'
const stepIndex = ref(0)
const direction = ref<'forward' | 'backward'>('forward')
const submitting = ref(false)
const route = useRoute()
const callback = computed(() => {
const value = route.query.callback?.toString()
return value?.startsWith('/') ? value : undefined
})

const form = reactive<FormState>({
organization: '',
Expand Down Expand Up @@ -73,7 +78,7 @@ async function onSubmit() {
})

await refreshNuxtData()
await navigateTo('/account/events')
await navigateTo(callback.value || '/account/events')
} catch (error) {
if (!(error instanceof FetchError)) {
throw error
Expand Down
2 changes: 1 addition & 1 deletion frontend/openapi/api/openapi.json

Large diffs are not rendered by default.

Loading