Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Private tasks list #24

Merged
merged 16 commits into from Mar 8, 2024
Merged
1 change: 0 additions & 1 deletion README.md
Expand Up @@ -36,4 +36,3 @@ open http://localhost:8080
#### Mobile

![screenshot 2](https://github.com/ubiquity/devpool-directory-ui/assets/4975670/b7861ce7-1f1f-49a9-b8e2-ebb20724ee67)

45 changes: 39 additions & 6 deletions build/esbuild-build.ts
@@ -1,13 +1,15 @@
import * as dotenv from "dotenv";
import { config } from "dotenv";
import esbuild from "esbuild";
import { invertColors } from "./plugins/invert-colors";
import { pwaManifest } from "./plugins/pwa-manifest";
import { execSync } from "child_process";
config();

const typescriptEntries = ["src/home/home.ts", "src/progressive-web-app.ts"];
const cssEntries = ["static/style/style.css"];
const entries = [...typescriptEntries, ...cssEntries, "static/manifest.json", "static/favicon.svg", "static/icon-512x512.png"];

export const esBuildContext: esbuild.BuildOptions = {
define: createEnvDefines(["SUPABASE_URL", "SUPABASE_ANON_KEY"]),
plugins: [invertColors, pwaManifest],
sourcemap: true,
entryPoints: entries,
Expand All @@ -23,23 +25,54 @@ export const esBuildContext: esbuild.BuildOptions = {
".json": "file",
},
outdir: "static/dist",
define: createEnvDefines(["SUPABASE_URL", "SUPABASE_ANON_KEY"], {
SUPABASE_STORAGE_KEY: generateSupabaseStorageKey(),
commitHash: execSync(`git rev-parse --short HEAD`).toString().trim(),
}),
};

esbuild
.build(esBuildContext)
.then(() => console.log("\tesbuild complete"))
.catch(console.error);

function createEnvDefines(variableNames: string[]): Record<string, string> {
function createEnvDefines(environmentVariables: string[], generatedAtBuild: Record<string, unknown>): Record<string, string> {
const defines: Record<string, string> = {};
dotenv.config();
for (const name of variableNames) {
for (const name of environmentVariables) {
const envVar = process.env[name];
if (envVar !== undefined) {
defines[`process.env.${name}`] = JSON.stringify(envVar);
defines[name] = JSON.stringify(envVar);
} else {
throw new Error(`Missing environment variable: ${name}`);
}
}
for (const key in generatedAtBuild) {
if (Object.prototype.hasOwnProperty.call(generatedAtBuild, key)) {
defines[key] = JSON.stringify(generatedAtBuild[key]);
}
}
return defines;
}

export function generateSupabaseStorageKey(): string | null {
const SUPABASE_URL = process.env.SUPABASE_URL;
if (!SUPABASE_URL) {
console.error("SUPABASE_URL environment variable is not set");
return null;
}

const urlParts = SUPABASE_URL.split(".");
if (urlParts.length === 0) {
console.error("Invalid SUPABASE_URL environment variable");
return null;
}

const domain = urlParts[0];
const lastSlashIndex = domain.lastIndexOf("/");
if (lastSlashIndex === -1) {
console.error("Invalid SUPABASE_URL format");
return null;
}

return domain.substring(lastSlashIndex + 1);
}
3 changes: 1 addition & 2 deletions cypress/e2e/devpool.cy.ts
Expand Up @@ -50,7 +50,6 @@ describe("DevPool", () => {
}).as("getIssues");
cy.visit("/");
cy.get('div[id="issues-container"]').children().should("have.length", 1);
cy.get("#issues-container > :nth-child(1)").should("have.class", "new-task");

// needed to make sure data is written to the local storage
cy.wait(3000);
Expand All @@ -65,7 +64,6 @@ describe("DevPool", () => {
}).as("getIssues");
cy.visit("/");
cy.get('div[id="issues-container"]').children().should("have.length", 1);
cy.get("#issues-container > :nth-child(1)").should("not.have.class", "new-task");

// needed to make sure data is written to the local storage
cy.wait(3000);
Expand Down Expand Up @@ -139,6 +137,7 @@ describe("DevPool", () => {
statusCode: 200,
});
// Simulate login token
// cSpell: ignore wfzpewmlyiozupulbuur
window.localStorage.setItem("sb-wfzpewmlyiozupulbuur-auth-token", JSON.stringify(loginToken));
}).as("githubLogin");
cy.visit("/");
Expand Down
2 changes: 1 addition & 1 deletion src/home/authentication.ts
Expand Up @@ -5,7 +5,7 @@ import { displayGitHubUserInformation } from "./rendering/display-github-user-in
import { renderGitHubLoginButton } from "./rendering/render-github-login-button";

export async function authentication() {
const accessToken = getGitHubAccessToken();
const accessToken = await getGitHubAccessToken();
if (!accessToken) {
renderGitHubLoginButton();
}
Expand Down
16 changes: 16 additions & 0 deletions src/home/fetch-github/fetch-and-display-previews.ts
@@ -1,3 +1,4 @@
import { getGitHubAccessToken } from "../getters/get-github-access-token";
import { getImageFromCache } from "../getters/get-indexed-db";
import { getLocalStore } from "../getters/get-local-store";
import { GITHUB_TASKS_STORAGE_KEY, TaskStorageItems } from "../github-types";
Expand All @@ -16,11 +17,26 @@ export type Options = {

export async function fetchAndDisplayPreviewsFromCache(sorting?: Sorting, options = { ordering: "normal" }) {
let _cachedTasks = getLocalStore(GITHUB_TASKS_STORAGE_KEY) as TaskStorageItems;
const _accessToken = await getGitHubAccessToken();

// Refresh the storage if there is no logged-in object in cachedTasks but there is one now.
if (_cachedTasks && !_cachedTasks.loggedIn && _accessToken) {
localStorage.removeItem(GITHUB_TASKS_STORAGE_KEY);
return fetchAndDisplayPreviewsFromNetwork(sorting, options);
}
0x4007 marked this conversation as resolved.
Show resolved Hide resolved

// If previously logged in but not anymore, clear cache and fetch from network.
if (_cachedTasks && _cachedTasks.loggedIn && !_accessToken) {
localStorage.removeItem(GITHUB_TASKS_STORAGE_KEY);
return fetchAndDisplayPreviewsFromNetwork(sorting, options);
}

// makes sure tasks have a timestamp to know how old the cache is, or refresh if older than 15 minutes
if (!_cachedTasks || !_cachedTasks.timestamp || _cachedTasks.timestamp + 60 * 1000 * 15 <= Date.now()) {
_cachedTasks = {
timestamp: Date.now(),
tasks: [],
loggedIn: _accessToken !== null,
};
}
const cachedTasks = _cachedTasks.tasks.map((task) => ({ ...task, isNew: false, isModified: false })) as TaskMaybeFull[];
Expand Down
2 changes: 1 addition & 1 deletion src/home/fetch-github/fetch-avatar.ts
Expand Up @@ -19,7 +19,7 @@ export async function fetchAvatar(orgName: string) {
}

// If not in IndexedDB, fetch from network
const octokit = new Octokit({ auth: getGitHubAccessToken() });
const octokit = new Octokit({ auth: await getGitHubAccessToken() });
try {
const {
data: { avatar_url: avatarUrl },
Expand Down
2 changes: 1 addition & 1 deletion src/home/fetch-github/fetch-issues-full.ts
Expand Up @@ -8,7 +8,7 @@ import { TaskMaybeFull, TaskWithFull } from "./preview-to-full-mapping";
export const organizationImageCache = new Map<string, Blob | null>();

export async function fetchIssuesFull(taskPreviews: TaskMaybeFull[]): Promise<TaskWithFull[]> {
const octokit = new Octokit({ auth: getGitHubAccessToken() });
const octokit = new Octokit({ auth: await getGitHubAccessToken() });
const urlPattern = /https:\/\/github\.com\/(?<org>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<issue_number>\d+)/;

const fullTaskPromises = taskPreviews.map(async (task) => {
Expand Down
71 changes: 67 additions & 4 deletions src/home/fetch-github/fetch-issues-preview.ts
@@ -1,18 +1,81 @@
import { Octokit } from "@octokit/rest";
import { getGitHubAccessToken } from "../getters/get-github-access-token";
import { getGitHubAccessToken, getGitHubUserName } from "../getters/get-github-access-token";
import { GitHubIssue } from "../github-types";
import { taskManager } from "../home";
import { displayPopupMessage } from "../rendering/display-popup-modal";
import { TaskNoFull } from "./preview-to-full-mapping";

async function checkPrivateRepoAccess(): Promise<boolean> {
const octokit = new Octokit({ auth: await getGitHubAccessToken() });
const username = getGitHubUserName();

if (username) {
try {
const response = await octokit.repos.checkCollaborator({
owner: "ubiquity",
repo: "devpool-directory-private",
username,
});

if (response.status === 204) {
// If the response is successful, it means the user has access to the private repository
return true;
}
return false;
} catch (error) {
if (error.status === 404) {
// If the status is 404, it means the user is not a collaborator, hence no access
return false;
} else {
// Handle other errors if needed
console.error("Error checking repository access:", error);
throw error;
}
}
}

return false;
}

export async function fetchIssuePreviews(): Promise<TaskNoFull[]> {
const octokit = new Octokit({ auth: getGitHubAccessToken() });
const octokit = new Octokit({ auth: await getGitHubAccessToken() });

let freshIssues: GitHubIssue[] = [];
let hasPrivateRepoAccess = false; // Flag to track access to the private repository

try {
const response = await octokit.paginate<GitHubIssue>("GET /repos/ubiquity/devpool-directory/issues", { state: "open" });
// Check if the user has access to the private repository
hasPrivateRepoAccess = await checkPrivateRepoAccess();

// Fetch issues from public repository
const { data: publicResponse } = await octokit.issues.listForRepo({
owner: "ubiquity",
repo: "devpool-directory",
state: "open",
});

const publicIssues = publicResponse.filter((issue: GitHubIssue) => !issue.pull_request);

freshIssues = response.filter((issue: GitHubIssue) => !issue.pull_request);
// Fetch issues from the private repository only if the user has access
if (hasPrivateRepoAccess) {
const { data: privateResponse } = await octokit.issues.listForRepo({
owner: "ubiquity",
repo: "devpool-directory-private",
state: "open",
});
const privateIssues = privateResponse.filter((issue: GitHubIssue) => !issue.pull_request);

// Mark private issues
const privateIssuesWithFlag = privateIssues.map((issue) => {
return issue;
});

// Combine public and private issues
freshIssues = [...privateIssuesWithFlag, ...publicIssues];
} else {
// If user doesn't have access, only load issues from the public repository
freshIssues = publicIssues;
}
} catch (error) {
if (403 === error.status) {
console.error(`GitHub API rate limit exceeded.`);
Expand Down
20 changes: 17 additions & 3 deletions src/home/getters/get-github-access-token.ts
@@ -1,12 +1,15 @@
declare const SUPABASE_STORAGE_KEY: string; // @DEV: passed in at build time check build/esbuild-build.ts
import { checkSupabaseSession } from "../rendering/render-github-login-button";
import { getLocalStore } from "./get-local-store";

export function getGitHubAccessToken(): string | null {
const oauthToken = getLocalStore("sb-wfzpewmlyiozupulbuur-auth-token") as OAuthToken | null;
export async function getGitHubAccessToken(): Promise<string | null> {
// better to use official function, looking up localstorage has flaws
const oauthToken = await checkSupabaseSession();

const expiresAt = oauthToken?.expires_at;
if (expiresAt) {
if (expiresAt < Date.now() / 1000) {
localStorage.removeItem("sb-wfzpewmlyiozupulbuur-auth-token");
localStorage.removeItem(`sb-${SUPABASE_STORAGE_KEY}-auth-token`);
return null;
}
}
Expand All @@ -19,6 +22,17 @@ export function getGitHubAccessToken(): string | null {
return null;
}

export function getGitHubUserName(): string | null {
const oauthToken = getLocalStore(`sb-${SUPABASE_STORAGE_KEY}-auth-token`) as OAuthToken | null;

const username = oauthToken?.user?.user_metadata?.user_name;
if (username) {
return username;
}

return null;
}

export interface OAuthToken {
provider_token: string;
access_token: string;
Expand Down
3 changes: 2 additions & 1 deletion src/home/getters/get-github-user.ts
Expand Up @@ -2,6 +2,7 @@ import { Octokit } from "@octokit/rest";
import { GitHubUser, GitHubUserResponse } from "../github-types";
import { OAuthToken } from "./get-github-access-token";
import { getLocalStore } from "./get-local-store";
declare const SUPABASE_STORAGE_KEY: string; // @DEV: passed in at build time check build/esbuild-build.ts

export async function getGitHubUser(): Promise<GitHubUser | null> {
const activeSessionToken = await getSessionToken();
Expand All @@ -13,7 +14,7 @@ export async function getGitHubUser(): Promise<GitHubUser | null> {
}

async function getSessionToken(): Promise<string | null> {
const cachedSessionToken = getLocalStore("sb-wfzpewmlyiozupulbuur-auth-token") as OAuthToken | null;
const cachedSessionToken = getLocalStore(`sb-${SUPABASE_STORAGE_KEY}-auth-token`) as OAuthToken | null;
if (cachedSessionToken) {
return cachedSessionToken.provider_token;
}
Expand Down