check authentication for all servers on initialization and update permission roles

This commit is contained in:
jeffvli
2025-12-07 17:53:26 -08:00
parent 4ddada1fe3
commit c82762a3fc
10 changed files with 283 additions and 44 deletions
+14
View File
@@ -586,6 +586,20 @@ export const controller: GeneralController = {
server.type, server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
}, },
getUserInfo(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getUserInfo`,
);
}
return apiController(
'getUserInfo',
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
getUserList(args) { getUserList(args) {
const server = getServerById(args.apiClientProps.serverId); const server = getServerById(args.apiClientProps.serverId);
@@ -247,6 +247,14 @@ export const contract = c.router({
400: jfType._response.error, 400: jfType._response.error,
}, },
}, },
getUser: {
method: 'GET',
path: 'users/:id',
responses: {
200: jfType._response.user,
400: jfType._response.error,
},
},
movePlaylistItem: { movePlaylistItem: {
body: null, body: null,
method: 'POST', method: 'POST',
@@ -1127,6 +1127,25 @@ export const JellyfinController: InternalControllerEndpoint = {
totalRecordCount: res.body.TotalRecordCount, totalRecordCount: res.body.TotalRecordCount,
}; };
}, },
getUserInfo: async (args) => {
const { apiClientProps, query } = args;
const res = await jfApiClient(apiClientProps).getUser({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to get user info');
}
return {
id: res.body.Id,
isAdmin: Boolean(res.body.Policy.IsAdministrator),
name: res.body.Name,
};
},
movePlaylistItem: async (args) => { movePlaylistItem: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -699,6 +699,7 @@ export const NavidromeController: InternalControllerEndpoint = {
}; };
}, },
getTopSongs: SubsonicController.getTopSongs, getTopSongs: SubsonicController.getTopSongs,
getUserInfo: SubsonicController.getUserInfo,
getUserList: async (args) => { getUserList: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -204,6 +204,14 @@ export const contract = c.router({
200: ssType._response.topSongsList, 200: ssType._response.topSongsList,
}, },
}, },
getUser: {
method: 'GET',
path: 'getUser.view',
query: ssType._parameters.user,
responses: {
200: ssType._response.user,
},
},
ping: { ping: {
method: 'GET', method: 'GET',
path: 'ping.view', path: 'ping.view',
@@ -144,7 +144,7 @@ export const SubsonicController: InternalControllerEndpoint = {
return { return {
credential, credential,
isAdmin: resp.body.user.adminRoles, isAdmin: Boolean(resp.body.user.adminRole),
userId: resp.body.user.username, userId: resp.body.user.username,
username: body.username, username: body.username,
}; };
@@ -1472,6 +1472,25 @@ export const SubsonicController: InternalControllerEndpoint = {
totalRecordCount: res.body.topSongs?.song?.length || 0, totalRecordCount: res.body.topSongs?.song?.length || 0,
}; };
}, },
getUserInfo: async (args) => {
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).getUser({
query: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to get user info');
}
return {
id: res.body.user.username,
isAdmin: Boolean(res.body.user.adminRole),
name: res.body.user.username,
};
},
removeFromPlaylist: async ({ apiClientProps, query }) => { removeFromPlaylist: async ({ apiClientProps, query }) => {
const res = await ssApiClient(apiClientProps).updatePlaylist({ const res = await ssApiClient(apiClientProps).updatePlaylist({
query: { query: {
@@ -1583,7 +1602,6 @@ export const SubsonicController: InternalControllerEndpoint = {
return null; return null;
}, },
search: async (args) => { search: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
+178 -36
View File
@@ -4,75 +4,217 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { getServerById, useAuthStoreActions, useCurrentServer } from '/@/renderer/store'; import { getServerById, useAuthStoreActions, useCurrentServer } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { AuthState } from '/@/shared/types/types';
import { AuthState, ServerListItem, ServerType } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null; const localSettings = isElectron() ? window.api.localSettings : null;
export const useServerAuthenticated = () => { export const useServerAuthenticated = () => {
const priorServerId = useRef<string | undefined>(undefined); const priorServerId = useRef<string | undefined>(undefined);
const server = useCurrentServer(); const server = useCurrentServer();
const [ready, setReady] = useState( const [ready, setReady] = useState(AuthState.VALID);
server?.type === ServerType.NAVIDROME ? AuthState.VALID : AuthState.VALID,
);
const { updateServer } = useAuthStoreActions(); const { setCurrentServer, updateServer } = useAuthStoreActions();
const authenticateNavidrome = useCallback( const authenticateServer = useCallback(
async (server: ServerListItem) => { async (serverWithAuth: NonNullable<ReturnType<typeof getServerById>>) => {
// This trick works because navidrome-api.ts will internally check for authentication
// failures and try to log in again (where available). So, all that's necessary is
// making one request first
try { try {
await api.controller.getSongList({ setReady(AuthState.LOADING);
apiClientProps: { serverId: server?.id || '' },
// Use userId if available, otherwise fall back to username (for Subsonic/Navidrome)
const userId = serverWithAuth.userId || serverWithAuth.username;
if (!userId) {
throw new Error('No user ID or username available');
}
// First, try getUserInfo to check if current credentials are still valid
logFn.info(logMsg[LogCategory.SYSTEM].authenticatingServer, {
category: LogCategory.SYSTEM,
meta: {
method: 'getUserInfo',
serverId: serverWithAuth.id,
serverName: serverWithAuth.name,
serverType: serverWithAuth.type,
},
});
try {
const userInfo = await api.controller.getUserInfo({
apiClientProps: {
serverId: serverWithAuth.id,
},
query: { query: {
limit: 1, id: userId,
sortBy: SongListSort.NAME, },
sortOrder: SortOrder.ASC, });
startIndex: 0,
if (!userInfo) {
throw new Error('Failed to get user info');
}
// Update server with user info (in case isAdmin changed)
updateServer(serverWithAuth.id, {
isAdmin: userInfo.isAdmin,
});
logFn.info(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, {
category: LogCategory.SYSTEM,
meta: {
isAdmin: userInfo.isAdmin,
method: 'getUserInfo',
serverId: serverWithAuth.id,
serverName: serverWithAuth.name,
serverType: serverWithAuth.type,
userId: userInfo.id,
}, },
}); });
setReady(AuthState.VALID); setReady(AuthState.VALID);
} catch (error) { return;
// Clear server credentials (and saved password). } catch (getUserInfoError: any) {
if (server.savePassword && localSettings) { // Check if it's a forbidden/authentication error (401 or 403)
localSettings.passwordRemove(server.id); const isForbiddenError =
getUserInfoError?.response?.status === 401 ||
getUserInfoError?.response?.status === 403 ||
getUserInfoError?.message?.toLowerCase().includes('forbidden') ||
getUserInfoError?.message?.toLowerCase().includes('unauthorized');
// Only reauthenticate if it's a forbidden error AND password is saved
if (isForbiddenError && serverWithAuth.savePassword && localSettings) {
const password = await localSettings.passwordGet(serverWithAuth.id);
if (password) {
logFn.info(logMsg[LogCategory.SYSTEM].authenticatingServer, {
category: LogCategory.SYSTEM,
meta: {
method: 'authenticate',
reason: 'getUserInfo failed with forbidden error',
serverId: serverWithAuth.id,
serverName: serverWithAuth.name,
serverType: serverWithAuth.type,
url: serverWithAuth.url,
},
});
// Authenticate using the API controller
const authData = await api.controller.authenticate(
serverWithAuth.url,
{
legacy: false,
password,
username: serverWithAuth.username,
},
serverWithAuth.type,
);
if (!authData) {
throw new Error('Authentication failed: No data returned');
} }
server.credential = ''; // Update server with new credentials
updateServer(server.id, server); const updatedServer = {
credential: authData.credential,
isAdmin: authData.isAdmin,
userId: authData.userId,
username: authData.username,
...(authData.ndCredential !== undefined && {
ndCredential: authData.ndCredential,
}),
};
toast.error({ message: (error as Error).message }); updateServer(serverWithAuth.id, updatedServer);
logFn.info(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, {
category: LogCategory.SYSTEM,
meta: {
isAdmin: authData.isAdmin,
method: 'authenticate',
serverId: serverWithAuth.id,
serverName: serverWithAuth.name,
serverType: serverWithAuth.type,
userId: authData.userId,
username: authData.username,
},
});
setReady(AuthState.VALID);
return;
}
}
// If not a forbidden error, or no password saved, rethrow the error
throw getUserInfoError;
}
} catch (error) {
const errorMessage = (error as Error).message || 'Authentication failed';
logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationFailed, {
category: LogCategory.SYSTEM,
meta: {
error: errorMessage,
serverId: serverWithAuth.id,
serverName: serverWithAuth.name,
serverType: serverWithAuth.type,
},
});
// Clear server credentials and saved password on failure
if (serverWithAuth.savePassword && localSettings) {
localSettings.passwordRemove(serverWithAuth.id);
}
toast.error({
message: errorMessage,
});
// Log the user out by setting current server to null
setCurrentServer(null);
setReady(AuthState.INVALID); setReady(AuthState.INVALID);
} }
}, },
[updateServer], [updateServer, setCurrentServer],
); );
const debouncedAuth = debounce((server: ServerListItem) => { const debouncedAuth = debounce(
authenticateNavidrome(server).catch(console.error); (serverWithAuth: NonNullable<ReturnType<typeof getServerById>>) => {
}, 300); authenticateServer(serverWithAuth).catch(console.error);
},
300,
);
useEffect(() => { useEffect(() => {
if (!server) { if (!server) {
logFn.debug(logMsg[LogCategory.SYSTEM].serverAuthenticationInvalid, {
category: LogCategory.SYSTEM,
meta: {
reason: 'No server selected',
},
});
setReady(AuthState.INVALID); setReady(AuthState.INVALID);
return; return;
} }
if (priorServerId.current !== server?.id) { if (priorServerId.current !== server.id) {
const serverWithAuth = getServerById(server!.id); const serverWithAuth = getServerById(server.id);
priorServerId.current = server?.id || ''; priorServerId.current = server.id;
if (server?.type === ServerType.NAVIDROME) { if (!serverWithAuth) {
setReady(AuthState.LOADING); logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationError, {
debouncedAuth(serverWithAuth!); category: LogCategory.SYSTEM,
} else { meta: {
setReady(AuthState.VALID); reason: 'Server not found in store',
serverId: server.id,
},
});
setReady(AuthState.INVALID);
return;
} }
setReady(AuthState.LOADING);
debouncedAuth(serverWithAuth);
} }
}, [debouncedAuth, server]); }, [debouncedAuth, server]);
+8 -1
View File
@@ -66,5 +66,12 @@ export const logMsg = {
scrobbledTimeupdate: 'Scrobbled a timeupdate event', scrobbledTimeupdate: 'Scrobbled a timeupdate event',
scrobbledUnpause: 'Scrobbled an unpause event', scrobbledUnpause: 'Scrobbled an unpause event',
}, },
[LogCategory.SYSTEM]: {}, [LogCategory.SYSTEM]: {
authenticatingServer: 'Authenticating server',
serverAuthenticationAborted: 'Server authentication aborted',
serverAuthenticationError: 'Server authentication error',
serverAuthenticationFailed: 'Server authentication failed',
serverAuthenticationInvalid: 'Server authentication invalid',
serverAuthenticationSuccess: 'Server authentication successful',
},
}; };
+10 -2
View File
@@ -7,9 +7,13 @@ const baseResponse = z.object({
}), }),
}); });
const authenticate = z.object({ const userParameters = z.object({
id: z.string(),
});
const user = z.object({
user: z.object({ user: z.object({
adminRoles: z.boolean(), adminRole: z.boolean(),
commentRole: z.boolean(), commentRole: z.boolean(),
coverArtRole: z.boolean(), coverArtRole: z.boolean(),
downloadRole: z.boolean(), downloadRole: z.boolean(),
@@ -26,6 +30,8 @@ const authenticate = z.object({
}), }),
}); });
const authenticate = user;
const authenticateParameters = z.object({ const authenticateParameters = z.object({
c: z.string(), c: z.string(),
f: z.string(), f: z.string(),
@@ -641,6 +647,7 @@ export const ssType = {
structuredLyrics: structuredLyricsParameters, structuredLyrics: structuredLyricsParameters,
topSongsList: topSongsListParameters, topSongsList: topSongsListParameters,
updatePlaylist: updatePlaylistParameters, updatePlaylist: updatePlaylistParameters,
user: userParameters,
}, },
_response: { _response: {
album, album,
@@ -683,5 +690,6 @@ export const ssType = {
song, song,
structuredLyrics, structuredLyrics,
topSongsList, topSongsList,
user,
}, },
}; };
+15 -1
View File
@@ -1276,7 +1276,6 @@ export type ControllerEndpoint = {
getAlbumInfo?: (args: AlbumDetailArgs) => Promise<AlbumInfo>; getAlbumInfo?: (args: AlbumDetailArgs) => Promise<AlbumInfo>;
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>; getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
getAlbumListCount: (args: AlbumListCountArgs) => Promise<number>; getAlbumListCount: (args: AlbumListCountArgs) => Promise<number>;
// getArtistInfo?: (args: any) => void;
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>; getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getArtistListCount: (args: ArtistListCountArgs) => Promise<number>; getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;
getDownloadUrl: (args: DownloadArgs) => string; getDownloadUrl: (args: DownloadArgs) => string;
@@ -1299,6 +1298,8 @@ export type ControllerEndpoint = {
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>; getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
getTagList?: (args: TagListArgs) => Promise<TagListResponse>; getTagList?: (args: TagListArgs) => Promise<TagListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>; getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
// getArtistInfo?: (args: any) => void;
getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;
getUserList?: (args: UserListArgs) => Promise<UserListResponse>; getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>; movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
@@ -1393,6 +1394,7 @@ export type InternalControllerEndpoint = {
) => Promise<StructuredLyric[]>; ) => Promise<StructuredLyric[]>;
getTagList?: (args: ReplaceApiClientProps<TagListArgs>) => Promise<TagListResponse>; getTagList?: (args: ReplaceApiClientProps<TagListArgs>) => Promise<TagListResponse>;
getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>; getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>;
getUserInfo: (args: ReplaceApiClientProps<UserInfoArgs>) => Promise<UserInfoResponse>;
getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>; getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>;
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>; movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
removeFromPlaylist: ( removeFromPlaylist: (
@@ -1509,6 +1511,18 @@ export type TagListResponse = {
}; };
}; };
export type UserInfoArgs = BaseEndpointArgs & { query: UserInfoQuery };
export type UserInfoQuery = {
id: string;
};
export type UserInfoResponse = {
id: string;
isAdmin: boolean;
name: string;
};
type BaseEndpointArgsWithServer = { type BaseEndpointArgsWithServer = {
apiClientProps: { apiClientProps: {
server: null | ServerListItemWithCredential; server: null | ServerListItemWithCredential;