Skip to content
Draft
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
51 changes: 45 additions & 6 deletions src/api/ApiUtils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
import { useQueries, useQuery } from '@tanstack/react-query';
import axios, { type AxiosError } from 'axios';
import {
useMutation,
useQueries,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
import axios, { type AxiosError, type AxiosInstance } from 'axios';
import { getAuthToken } from '../auth/token';

// Dedicated client for the das-gittensor API. A request interceptor attaches the
// session JWT as a Bearer token when present, so authed writes (PATCH/POST/DELETE
// /repos) work while public GETs are unaffected. Deliberately NOT a global axios
// interceptor: githubFetch / the mirror client hit other origins and must never
// receive our token.
export const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_REACT_APP_BASE_URL || undefined,
});

apiClient.interceptors.request.use((config) => {
const token = getAuthToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});

export const useMirrorApiQueries = <TResponse = unknown, TSelect = TResponse>(
queryName: string,
Expand Down Expand Up @@ -32,13 +55,10 @@ export const useApiQuery = <TResponse = void, TSelect = TResponse>(
queryParams?: Record<string, string | number | undefined>,
enabled?: boolean,
) => {
const baseUrl = import.meta.env.VITE_REACT_APP_BASE_URL;

return useQuery<TResponse, AxiosError, TSelect>({
queryKey: [queryName, url, queryParams],
queryFn: async () => {
const requestUrl = baseUrl ? `${baseUrl}${url}` : url;
const { data } = await axios.get(requestUrl, { params: queryParams });
const { data } = await apiClient.get(url, { params: queryParams });
return data;
},
retry: false,
Expand All @@ -47,6 +67,25 @@ export const useApiQuery = <TResponse = void, TSelect = TResponse>(
});
};

// Mutation helper for authed das-gittensor writes (PATCH/POST/DELETE). The caller
// supplies a function that performs the request via the injected `apiClient`
// (Bearer token attached automatically) and returns the response body; on success
// any `invalidateKeys` query keys are refetched so the UI reflects the write.
export const useApiMutation = <TVars, TResponse = unknown>(
mutationFn: (client: AxiosInstance, vars: TVars) => Promise<TResponse>,
options?: { invalidateKeys?: readonly unknown[][] },
) => {
const queryClient = useQueryClient();
return useMutation<TResponse, AxiosError, TVars>({
mutationFn: (vars) => mutationFn(apiClient, vars),
onSuccess: () => {
options?.invalidateKeys?.forEach((queryKey) =>
queryClient.invalidateQueries({ queryKey }),
);
},
});
};

// Mirror API (https://mirror.gittensor.io/api/v1) — returns raw snake_case
// payloads, so callers receive the response as-is and may transform it via
// `useQuery`'s `select`. Kept separate from `useApiQuery` so the camelCase
Expand Down
84 changes: 83 additions & 1 deletion src/api/ReposApi.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
// Repository API hooks - uses /repos endpoints
import { useApiQuery } from './ApiUtils';
import { useApiMutation, useApiQuery } from './ApiUtils';
import { type RepositoryMaintainer, type RepositoryIssue } from './models';
import { type Repository, type RepositoryMiner } from './models/Dashboard';

/** One audit row from GET /repos/:repo/config-history (newest first). */
export type RepositoryConfigEdit = {
id: string;
editorLogin: string | null;
editorGithubId: string;
isAdmin: boolean;
changedKeys: string[] | null;
configBefore: Record<string, unknown> | null;
configAfter: Record<string, unknown> | null;
note: string | null;
createdAt: string;
};

/** Body for a maintainer/admin hyperparameter edit (snake_case config keys). */
export type RepositoryConfigPatch = {
config: Record<string, unknown>;
note?: string;
};

/**
* Helper to create /repos endpoint queries
*/
Expand Down Expand Up @@ -59,3 +78,66 @@ export const useRepositoryMiners = (repo: string) =>
'useRepositoryMiners',
`/${encodeURIComponent(repo)}/miners`,
);

/**
* Hyperparameter edit history for a repository (audit trail), newest first.
* @param repo - Full repository name (e.g., "opentensor/btcli")
*/
export const useRepositoryConfigHistory = (repo: string) =>
useReposQuery<RepositoryConfigEdit[]>(
'useRepositoryConfigHistory',
`/${encodeURIComponent(repo)}/config-history`,
);

// ---------------------------------------------------------------------------
// Authed writes (require a GitHub session — see AuthContext). On success they
// invalidate the affected repo reads so the UI reflects the change immediately.
// ---------------------------------------------------------------------------

/** Maintainer/admin edit of a repo's hyperparameters (PATCH /repos/:repo/config). */
export const useUpdateRepositoryConfig = (repo: string) =>
useApiMutation<RepositoryConfigPatch, Repository>(
(client, body) =>
client
.patch<Repository>(`/repos/${encodeURIComponent(repo)}/config`, body)
.then((r) => r.data),
{
invalidateKeys: [['useRepositoryConfig'], ['useRepositoryConfigHistory']],
},
);

/** Admin: set a repo's emission_share (PATCH /repos/:repo/emission-share). */
export const useSetEmissionShare = (repo: string) =>
useApiMutation<{ emissionShare: number }, Repository>(
(client, body) =>
client
.patch<Repository>(
`/repos/${encodeURIComponent(repo)}/emission-share`,
body,
)
.then((r) => r.data),
{
invalidateKeys: [['useRepositoryConfig'], ['useRepositoryConfigHistory']],
},
);

/** Admin: register a new repository (POST /repos). */
export const useRegisterRepository = () =>
useApiMutation<
{ fullName: string; config?: Record<string, unknown> },
Repository
>(
(client, body) =>
client.post<Repository>('/repos', body).then((r) => r.data),
{ invalidateKeys: [['useRepositoryConfig']] },
);

/** Admin: remove a repository (DELETE /repos/:repo). */
export const useDeleteRepository = () =>
useApiMutation<{ repo: string }, void>(
(client, { repo }) =>
client
.delete<void>(`/repos/${encodeURIComponent(repo)}`)
.then((r) => r.data),
{ invalidateKeys: [['useRepositoryConfig']] },
);
127 changes: 127 additions & 0 deletions src/auth/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* GitHub-OAuth session state for the das-gittensor API.
*
* Flow (matches das AuthController):
* 1. login() sends the browser to `{API}/auth/github`; das bounces through
* GitHub and redirects back to this app with `#token=<jwt>` in the fragment.
* 2. On mount we capture that fragment, persist the JWT (see token.ts), and
* strip it from the URL so it isn't left in history / shared links.
* 3. With a token present we call `GET /auth/me` to hydrate the user (and learn
* whether they're an admin). A 401 (expired/invalid) clears the session.
*
* The token is attached to API requests by the apiClient interceptor in
* ApiUtils; this context only owns identity + login/logout.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { apiClient } from '../api/ApiUtils';
import { clearAuthToken, getAuthToken, setAuthToken } from './token';

export type AuthUser = {
githubId: string;
login: string;
name: string | null;
avatarUrl: string | null;
isAdmin: boolean;
};

type AuthContextValue = {
user: AuthUser | null;
isAuthenticated: boolean;
isAdmin: boolean;
/** True while the initial /auth/me hydration is in flight. */
loading: boolean;
login: () => void;
logout: () => void;
};

const AuthContext = createContext<AuthContextValue | undefined>(undefined);

/** Pull `#token=...` out of the post-login redirect fragment, if present. */
function consumeTokenFromHash(): string | null {
const hash = window.location.hash;
if (!hash.startsWith('#')) return null;
const params = new URLSearchParams(hash.slice(1));
const token = params.get('token');
if (!token) return null;
// Remove the fragment without adding a history entry.
const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}`);
return token;
}

export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
const fromHash = consumeTokenFromHash();
if (fromHash) {
setAuthToken(fromHash);
}

const token = getAuthToken();
if (!token) {
setLoading(false);
return;
}

let cancelled = false;
apiClient
.get<AuthUser>('/auth/me')
.then(({ data }) => {
if (!cancelled) setUser(data);
})
.catch(() => {
// Expired or invalid session — drop it so the UI shows logged-out.
clearAuthToken();
if (!cancelled) setUser(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
});

return () => {
cancelled = true;
};
}, []);

const login = useCallback(() => {
const base = import.meta.env.VITE_REACT_APP_BASE_URL ?? '';
window.location.href = `${base}/auth/github`;
}, []);

const logout = useCallback(() => {
clearAuthToken();
setUser(null);
}, []);

const value = useMemo<AuthContextValue>(
() => ({
user,
isAuthenticated: user !== null,
isAdmin: user?.isAdmin ?? false,
loading,
login,
logout,
}),
[user, loading, login, logout],
);

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (ctx === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return ctx;
}
34 changes: 34 additions & 0 deletions src/auth/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Session-token storage for the das-gittensor API.
*
* The token is an 8h JWT handed back by das after GitHub OAuth (delivered in the
* post-login redirect fragment, see AuthContext). It is read here by both the
* axios request interceptor (to attach `Authorization: Bearer`) and AuthContext.
* localStorage access is wrapped so SSR / privacy-mode failures degrade to
* "logged out" rather than throwing.
*/
const TOKEN_KEY = 'gittensor.authToken';

export const getAuthToken = (): string | null => {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
};

export const setAuthToken = (token: string): void => {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
/* storage unavailable — token stays in-memory only for this page load */
}
};

export const clearAuthToken = (): void => {
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
/* nothing to do */
}
};
Loading
Loading