diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 86f38a1141..64c4c433a2 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -15,6 +15,7 @@ import { LeftArrowIcon, LibraryIcon, NotepadTextIcon, + OrganizationIcon, RefreshCwIcon, RightArrowIcon, SettingsIcon, @@ -582,9 +583,15 @@ watch(incompatibilityWarningModal, (modal) => { } }) -setupAuthProvider(credentials, async (_redirectPath) => { - await signIn() -}) +setupAuthProvider( + credentials, + async (_redirectPath) => { + await signIn() + }, + async () => { + await fetchCredentials() + }, +) async function validateSession(sessionToken) { try { @@ -1280,6 +1287,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload) + + + { return await invoke('plugin:mr-auth|get') } +export type UserProject = { + id: string + slug: string | null + name: string + summary: string + description: string + icon_url: string | null + color: number | null + status: string + project_types: string[] + organization: string | null + downloads: number + followers: number +} + +export type UserOrganization = { + id: string + slug: string + name: string + description: string + icon_url: string | null + color: number | null +} + +export type UserAllProjects = { + projects: UserProject[] + organizations: Record +} + +/** + * Returns every project the signed-in user can access — their own projects plus + * every project owned by an organization they belong to — including non-public + * statuses (unlisted, private). Resolves to `null` when no user is signed in. + */ +export async function getUserProjects(): Promise { + return await invoke('plugin:mr-auth|get_user_projects') +} + export async function cancelLogin(): Promise { return await invoke('plugin:mr-auth|cancel_modrinth_login') } diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue index 69850eda17..cb9ccc91d1 100644 --- a/apps/app-frontend/src/pages/Index.vue +++ b/apps/app-frontend/src/pages/Index.vue @@ -1,13 +1,18 @@ + + diff --git a/apps/app-frontend/src/pages/index.js b/apps/app-frontend/src/pages/index.js index d08a3fbeb6..b7a3c82b7b 100644 --- a/apps/app-frontend/src/pages/index.js +++ b/apps/app-frontend/src/pages/index.js @@ -2,5 +2,6 @@ import Browse from './Browse.vue' import Index from './Index.vue' import Skins from './Skins.vue' import Worlds from './Worlds.vue' +import YourProjects from './YourProjects.vue' -export { Browse, Index, Skins, Worlds } +export { Browse, Index, Skins, Worlds, YourProjects } diff --git a/apps/app-frontend/src/providers/setup/auth.ts b/apps/app-frontend/src/providers/setup/auth.ts index 8832c09c6a..cefbf307cb 100644 --- a/apps/app-frontend/src/providers/setup/auth.ts +++ b/apps/app-frontend/src/providers/setup/auth.ts @@ -10,6 +10,7 @@ type AppCredentials = { export function setupAuthProvider( credentials: Ref, requestSignIn: (redirectPath: string) => void | Promise, + refreshSession?: () => Promise, ) { const sessionToken = ref(null) const user = ref(null) @@ -20,6 +21,7 @@ export function setupAuthProvider( user, isReady, requestSignIn, + refreshSession, } watchEffect(() => { diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index d8d0343e23..4bcf31163a 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -44,6 +44,14 @@ export default new createRouter({ breadcrumb: [{ name: 'Skin selector' }], }, }, + { + path: '/your-projects', + name: 'Your projects', + component: Pages.YourProjects, + meta: { + breadcrumb: [{ name: 'Your projects' }], + }, + }, { path: '/library', name: 'Library', diff --git a/apps/app-frontend/src/store/theme.ts b/apps/app-frontend/src/store/theme.ts index d06ab07bae..e5411d9f87 100644 --- a/apps/app-frontend/src/store/theme.ts +++ b/apps/app-frontend/src/store/theme.ts @@ -7,6 +7,7 @@ export const DEFAULT_FEATURE_FLAGS = { page_path: false, worlds_tab: false, worlds_in_home: true, + your_projects_tab: false, skip_non_essential_warnings: false, i18n_debug: false, show_instance_play_time: true, diff --git a/apps/app/src/api/mr_auth.rs b/apps/app/src/api/mr_auth.rs index 2143d20c5e..a39bb1edfe 100644 --- a/apps/app/src/api/mr_auth.rs +++ b/apps/app/src/api/mr_auth.rs @@ -14,6 +14,7 @@ pub fn init() -> TauriPlugin { modrinth_login, logout, get, + get_user_projects, cancel_modrinth_login, ]) .build() @@ -77,6 +78,11 @@ pub async fn get() -> Result> { Ok(theseus::mr_auth::get_credentials().await?) } +#[tauri::command] +pub async fn get_user_projects() -> Result> { + Ok(theseus::mr_auth::get_user_projects().await?) +} + #[tauri::command] pub fn cancel_modrinth_login() { oauth_utils::auth_code_reply::stop_listeners(); diff --git a/packages/app-lib/src/api/mr_auth.rs b/packages/app-lib/src/api/mr_auth.rs index 42ce41fe5e..1df7a0e225 100644 --- a/packages/app-lib/src/api/mr_auth.rs +++ b/packages/app-lib/src/api/mr_auth.rs @@ -1,4 +1,6 @@ -use crate::state::ModrinthCredentials; +use crate::state::{AUTHENTICATED_CACHE_TYPES, CachedEntry, ModrinthCredentials}; +use crate::util::fetch::fetch_json; +use reqwest::Method; #[tracing::instrument] pub fn authenticate_begin_flow() -> &'static str { @@ -19,6 +21,13 @@ pub async fn authenticate_finish_flow( .await?; creds.upsert(&state.pool).await?; + + // Signing in deactivates any other account without removing it, so this is + // the one identity change that doesn't go through `remove`. Drop the cached + // data the previous session was allowed to see before this one reads it. + CachedEntry::purge_cache_types(AUTHENTICATED_CACHE_TYPES, &state.pool) + .await?; + state .friends_socket .connect(&state.pool, &state.api_semaphore, &state.process_manager) @@ -33,6 +42,8 @@ pub async fn logout() -> crate::Result<()> { let current = ModrinthCredentials::get_active(&state.pool).await?; if let Some(current) = current { + // Removing the credentials also purges the cached data they gave + // access to. ModrinthCredentials::remove(¤t.user_id, &state.pool).await?; state.friends_socket.disconnect().await?; } @@ -40,6 +51,44 @@ pub async fn logout() -> crate::Result<()> { Ok(()) } +/// Fetches every project the signed-in user has access to (their personal +/// projects plus every project owned by an organization they belong to), +/// including non-public statuses such as unlisted and private. +/// +/// Returns `None` when no user is signed in. The result is fetched fresh on +/// each call rather than cached, so it always reflects the currently active +/// credentials instead of a response captured under a different auth state. +#[tracing::instrument] +pub async fn get_user_projects() -> crate::Result> { + let state = crate::State::get().await?; + + let Some(creds) = + ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore) + .await? + else { + return Ok(None); + }; + + let url = format!( + "{}user/{}/all-projects", + env!("MODRINTH_API_URL_V3"), + creds.user_id + ); + + let projects = fetch_json( + Method::GET, + &url, + None, + None, + Some("/v3/user/all-projects"), + &state.api_semaphore, + &state.pool, + ) + .await?; + + Ok(Some(projects)) +} + #[tracing::instrument] pub async fn get_credentials() -> crate::Result> { let state = crate::State::get().await?; diff --git a/packages/app-lib/src/state/mr_auth.rs b/packages/app-lib/src/state/mr_auth.rs index 09b735ea53..2adce19a46 100644 --- a/packages/app-lib/src/state/mr_auth.rs +++ b/packages/app-lib/src/state/mr_auth.rs @@ -1,4 +1,4 @@ -use crate::state::{CacheBehaviour, CachedEntry}; +use crate::state::{CacheBehaviour, CacheValueType, CachedEntry}; use crate::util::fetch::{FetchSemaphore, fetch_advanced}; use chrono::{DateTime, Duration, TimeZone, Utc}; use dashmap::DashMap; @@ -6,6 +6,19 @@ use futures::TryStreamExt; use reqwest::Method; use serde::{Deserialize, Serialize}; +/// Cache types whose entries can hold data only a signed-in user was allowed to +/// see, such as private and unlisted projects. The cache is keyed by id and not +/// partitioned by account, so these must not outlive the session that was +/// allowed to see them. +pub const AUTHENTICATED_CACHE_TYPES: &[CacheValueType] = &[ + CacheValueType::Project, + CacheValueType::ProjectV3, + CacheValueType::ProjectVersions, + CacheValueType::Version, + CacheValueType::Team, + CacheValueType::Organization, +]; + #[derive(Serialize, Deserialize, Clone, Debug)] pub struct ModrinthCredentials { pub session: String, @@ -159,7 +172,7 @@ impl ModrinthCredentials { pub async fn remove( user_id: &str, - exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, + exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result<()> { sqlx::query!( " @@ -170,6 +183,10 @@ impl ModrinthCredentials { .execute(exec) .await?; + // Purging here covers every way a session ends: signing out, and a + // session expiring when its refresh fails. + CachedEntry::purge_cache_types(AUTHENTICATED_CACHE_TYPES, exec).await?; + Ok(()) } diff --git a/packages/ui/src/providers/auth.ts b/packages/ui/src/providers/auth.ts index b9de5eb62b..5094a3ac86 100644 --- a/packages/ui/src/providers/auth.ts +++ b/packages/ui/src/providers/auth.ts @@ -11,6 +11,12 @@ export interface AuthProvider { /** True once the initial auth check has completed (regardless of result). */ isReady?: Ref requestSignIn: (redirectPath: string) => void | Promise + /** + * Re-reads the stored credentials and updates this provider. Use when a + * request reveals the session is no longer valid, so the rest of the app + * doesn't keep showing the user as signed in. + */ + refreshSession?: () => Promise } export const [injectAuth, provideAuth] = createContext('root', 'auth')