use django User model

This commit is contained in:
alex
2021-09-02 17:27:07 +01:00
parent 1063d6bb91
commit 60c23c3cc4
18 changed files with 489 additions and 261 deletions

View File

@@ -1,8 +1,10 @@
from django.contrib import admin
from .models import Profile, Assembly, Compilation, Asm, Scratch
from .github import GitHubUser
admin.site.register(Profile)
admin.site.register(GitHubUser)
admin.site.register(Asm)
admin.site.register(Assembly)
admin.site.register(Compilation)

125
backend/coreapp/github.py Normal file
View File

@@ -0,0 +1,125 @@
from django.conf import settings
from django.core.cache import cache
from django.db import models
from django.http import HttpRequest
from django.contrib.auth import login
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.exceptions import APIException
from typing import Optional
from github3api import GitHubAPI
import requests
from .models import Profile
API_CACHE_TIMEOUT = 60 * 60 # 1 hour
class BadOAuthCode(APIException):
status_code = status.HTTP_401_UNAUTHORIZED
default_detail = "Invalid or expired GitHub OAuth verification code."
default_code = "bad_oauth_code"
class MissingOAuthScope(APIException):
status_code = status.HTTP_400_BAD_REQUEST
default_code = "bad_oauth_scope"
def __init__(self, scope: str):
super(f"The GitHub OAuth verification code was valid but lacks required scope '{scope}'.")
class GitHubUserDetails:
def __init__(self, json):
self.id: int = json["id"]
self.username: str = json["login"]
self.email: Optional[str] = json["email"]
self.avatar_url: str = json["avatar_url"]
self.name: str = json["name"]
self.html_url: str = json["html_url"]
class GitHubUser(models.Model):
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
primary_key=True,
related_name="github",
)
github_id = models.PositiveIntegerField(unique=True, editable=False)
access_token = models.CharField(max_length=100)
def details(self, use_cache: bool = True) -> GitHubUserDetails:
cache_key = f"github_user_details:{self.github_id}"
cached = cache.get(cache_key) if use_cache else None
if cached:
return cached
data = GitHubAPI(bearer_token=self.access_token).get(f"/user/{self.github_id}")
details = GitHubUserDetails(data)
cache.set(cache_key, details, API_CACHE_TIMEOUT)
return details
def github_api_url(self):
return f"https://api.github.com/user/{self.github_id}"
def __str__(self):
return "@" + self.details().username
@staticmethod
def login(request: HttpRequest, oauth_code: str) -> "GitHubUser":
response = requests.post(
"https://github.com/login/oauth/access_token",
json={
"client_id": settings.GITHUB_CLIENT_ID,
"client_secret": settings.GITHUB_CLIENT_SECRET,
"code": oauth_code,
},
headers={ "Accept": "application/json" },
).json()
error: Optional[str] = response.get("error")
if error == "bad_verification_code":
raise BadOAuthCode()
elif error:
raise Exception(f"GitHub login sent unknown error '{error}'")
scopes = str(response["scope"]).split(",")
if not "public_repo" in scopes:
raise MissingOAuthScope("public_repo")
access_token: str = response["access_token"]
details = GitHubUserDetails(GitHubAPI(bearer_token=access_token).get("/user"))
try:
gh_user = GitHubUser.objects.get(github_id=details.id)
except GitHubUser.DoesNotExist:
gh_user = GitHubUser()
user = request.user
new_user = request.user.is_anonymous
try:
request.user.github
new_user = True
except User.github.RelatedObjectDoesNotExist:
# request.user lacks a github link, so we can attach gh_user to it
pass
if new_user:
user = User.objects.create_user(
username=details.username,
email=details.email,
password=None,
)
user.profile = request.user.profile
user.save()
gh_user.user = user
gh_user.github_id = details.id
gh_user.access_token = access_token
gh_user.save()
login(request, gh_user.user)
return gh_user

View File

@@ -0,0 +1,39 @@
from django.http.request import HttpRequest
from django.utils.timezone import now
from .models import User, Profile
import logging
def set_user_profile(get_response):
"""
Makes sure that `request.user.profile` is always available, even for anonymous users.
"""
def middleware(request: HttpRequest):
profile = None
if not request.user.is_anonymous:
try:
profile = request.user.profile
except User.profile.RelatedObjectDoesNotExist:
pass
if not profile:
try:
profile = Profile.objects.get(id=request.session.get("anonymous_profile_id"))
except Profile.DoesNotExist:
profile = Profile()
if not request.user.is_anonymous:
profile.user = request.user
profile.save()
request.session["anonymous_profile_id"] = profile.id
logging.debug(f"New anonymous profile: {profile}")
profile.last_request_date = now()
profile.save()
return get_response(request)
return middleware

View File

@@ -0,0 +1,67 @@
# Generated by Django 3.2.6 on 2021-08-31 09:11
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('coreapp', '0003_profile_github'),
]
operations = [
migrations.CreateModel(
name='GitHubUser',
fields=[
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='github', serialize=False, to='auth.user')),
('github_id', models.PositiveIntegerField(editable=False, unique=True)),
('access_token', models.CharField(max_length=100)),
],
),
migrations.RemoveField(
model_name='profile',
name='avatar_url',
),
migrations.RemoveField(
model_name='profile',
name='github_access_token',
),
migrations.RemoveField(
model_name='profile',
name='github_load_time',
),
migrations.RemoveField(
model_name='profile',
name='github_user_id',
),
migrations.RemoveField(
model_name='profile',
name='name',
),
migrations.RemoveField(
model_name='profile',
name='username',
),
migrations.AddField(
model_name='profile',
name='creation_date',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='profile',
name='last_request_date',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='profile',
name='user',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -1,10 +1,6 @@
from django.utils.crypto import get_random_string
from django.utils.timezone import now
from datetime import timedelta
import logging
from django.db import models
from github3api import GitHubAPI
from django.contrib.auth.models import User
def gen_scratch_id() -> str:
ret = get_random_string(length=5)
@@ -14,65 +10,24 @@ def gen_scratch_id() -> str:
return ret
class GitHubUserChangeException(Exception):
"""
The github_access_token is for a different GitHub user than is connected to this Profile.
Assign it to a new Profile instead.
"""
class GitHubUserHasExistingProfileException(Exception):
"""
The github_access_token is for a GitHub user who already has a Profile; use that instead.
"""
def __init__(self, profile: "Profile"):
super()
self.profile = profile
class Profile(models.Model):
github_access_token = models.TextField(blank=True)
github_load_time = models.DateTimeField(auto_now_add=True)
creation_date = models.DateTimeField(auto_now_add=True)
last_request_date = models.DateTimeField(auto_now_add=True)
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name="profile",
null=True,
)
# Fields taken from GitHub - don't modify these outside of load_fields_from_github
github_user_id = models.TextField(null=True)
username = models.TextField(null=True)
name = models.TextField(null=True)
avatar_url = models.TextField(null=True)
def is_anonymous(self):
return self.user is None
def load_fields_from_github(self, always=False) -> bool:
if not self.github_access_token:
return False
time = self.github_load_time
delta_time = now() - time
if always or delta_time > timedelta(days=1):
gh = GitHubAPI(bearer_token=self.github_access_token)
data = gh.get("/user")
existing_profile = Profile.objects.filter(github_user_id=data["id"]).first()
if existing_profile and existing_profile.id != self.id:
raise GitHubUserHasExistingProfileException(existing_profile)
github_user_id = str(data["id"])
if self.github_user_id is not None and github_user_id != self.github_user_id:
raise GitHubUserChangeException()
self.github_load_time = now()
self.github_user_id = github_user_id
self.username = data["login"]
self.name = data["name"]
self.avatar_url = data["avatar_url"]
logging.debug(f"Loaded fields from GitHub user @{self.username} into profile {self.id}")
return True
else:
return False
def __str__(self):
return self.username if self.username else str(self.id)
if self.user:
return self.user.username
else:
return str(self.id)
class Asm(models.Model):
hash = models.CharField(max_length=64, primary_key=True)

View File

@@ -1,11 +1,45 @@
from coreapp.models import Profile, Scratch
from django.contrib.auth.models import User
from rest_framework import serializers
from rest_framework.request import Request
from typing import Union, Optional
class ProfileSerializer(serializers.ModelSerializer[Profile]):
class Meta:
model = Profile
fields = ["id", "username", "name", "avatar_url"]
from .models import Profile, Scratch
from .github import GitHubUser
def serialize_user(request: Request, user: Union[User, Profile]):
if isinstance(user, Profile):
user: User = user.user
github: Optional[GitHubUser] = None
try:
github = user.github
except User.github.RelatedObjectDoesNotExist:
pass
except AttributeError:
pass
if user.is_anonymous:
return {
"is_you": user == request.user,
"is_anonymous": True,
"id": user.id,
}
else:
return {
"is_you": user == request.user,
"is_anonymous": False,
"id": user.id,
"username": user.username,
"email": user.email,
"name": github.details().name if github else user.username,
"avatar_url": github.details().avatar_url if github else None,
"github_api_url": github.github_api_url() if github else None,
"github_html_url": github.details().html_url if github else None,
}
class UserField(serializers.RelatedField):
def to_representation(self, user: Union[User, Profile]):
return serialize_user(self.context["request"], user)
class ScratchCreateSerializer(serializers.Serializer[None]):
compiler = serializers.CharField(allow_blank=True, required=False)
@@ -16,15 +50,6 @@ class ScratchCreateSerializer(serializers.Serializer[None]):
# TODO: `context` should be renamed; it conflicts with Field.context
context = serializers.CharField(allow_blank=True) # type: ignore
class ScratchMetadataSerializer(serializers.ModelSerializer[Scratch]):
owner = ProfileSerializer()
class Meta:
model = Scratch
fields = ["slug", "owner"]
class ScratchSerializer(serializers.ModelSerializer[Scratch]):
class Meta:
model = Scratch
@@ -38,12 +63,15 @@ class ScratchSerializer(serializers.ModelSerializer[Scratch]):
return scratch
# XXX: ideally we would just use ScratchSerializer, but adding owner and parent breaks creation
class ScratchWithMetadataSerializer(serializers.ModelSerializer[Scratch]):
owner = ProfileSerializer()
parent = ScratchMetadataSerializer()
owner = UserField(read_only=True)
parent = serializers.HyperlinkedRelatedField(
read_only=True,
view_name="scratch-detail",
lookup_field="slug",
)
class Meta:
model = Scratch
fields = ["slug", "compiler", "cc_opts", "target_assembly", "source_code", "context", "owner", "parent"]
fields = ["slug", "compiler", "cc_opts", "source_code", "context", "owner", "parent"]

View File

@@ -4,10 +4,10 @@ from . import views
urlpatterns = [
path('compilers', views.compilers, name='compilers'),
path('scratch', views.scratch, name='scratch'),
path('scratch/<slug:slug>', views.scratch, name='scratch'),
path('scratch', views.scratch, name='scratch'), # TODO make this into its own view
path('scratch/<slug:slug>', views.scratch, name='scratch-detail'),
path('scratch/<slug:slug>/compile', views.compile, name='compile_scratch'),
path('scratch/<slug:slug>/fork', views.fork, name='fork_scratch'),
path('user', views.user_current, name="user_current"),
path('user/<slug:username>', views.user, name="user"),
path('user', views.CurrentUser.as_view()),
path('users/<slug:username>', views.user, name="user-detail"),
]

View File

@@ -1,19 +1,22 @@
from typing import Optional
from coreapp.asm_diff_wrapper import AsmDifferWrapper
from coreapp.m2c_wrapper import M2CWrapper
from coreapp.compiler_wrapper import CompilerWrapper
from coreapp.serializers import ScratchCreateSerializer, ScratchSerializer, ScratchWithMetadataSerializer, ProfileSerializer
from coreapp.serializers import ScratchCreateSerializer, ScratchSerializer, ScratchWithMetadataSerializer, serialize_user
from django.shortcuts import get_object_or_404
from django.conf import settings
from django.contrib.auth import logout
from rest_framework import serializers, status
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.decorators import api_view
from rest_framework.request import Request
import logging
import hashlib
import requests
from github3api import GitHubAPI
from .models import Profile, Asm, Scratch, GitHubUserChangeException, GitHubUserHasExistingProfileException
from .models import User, Profile, Asm, Scratch
from .github import GitHubUser
from coreapp.models import gen_scratch_id
@@ -46,12 +49,8 @@ def scratch(request, slug=None):
if not db_scratch.owner:
# Give ownership to this profile
profile = Profile.objects.filter(id=request.session.get("profile", None)).first()
if not profile:
profile = Profile()
profile.save()
request.session["profile"] = profile.id
print(request.user)
profile = request.user.profile
logging.debug(f"Granting ownership of scratch {db_scratch} to {profile}")
@@ -59,8 +58,7 @@ def scratch(request, slug=None):
db_scratch.save()
return Response({
"scratch": ScratchWithMetadataSerializer(db_scratch).data,
"is_yours": db_scratch.owner.id == request.session.get("profile", None),
"scratch": ScratchWithMetadataSerializer(db_scratch, context={ "request": request }).data,
})
elif request.method == "POST":
@@ -140,7 +138,7 @@ def scratch(request, slug=None):
db_scratch = get_object_or_404(Scratch, slug=slug)
if db_scratch.owner and db_scratch.owner.id != request.session.get("profile", None):
if db_scratch.owner and db_scratch.owner != request.user.profile:
return Response(status=status.HTTP_403_FORBIDDEN)
# TODO validate
@@ -215,86 +213,36 @@ def fork(request, slug):
parent=parent_scratch,
)
new_scratch.save()
return Response(ScratchSerializer(new_scratch).data, status=status.HTTP_201_CREATED)
return Response(
ScratchSerializer(new_scratch, context={ "request": request }).data,
status=status.HTTP_201_CREATED
)
@api_view(["GET", "POST"])
def user_current(request, slug=None):
class CurrentUser(APIView):
"""
Get the logged-in user, or sign in with GitHub
View to access the current user profile.
"""
profile = Profile.objects.filter(id=request.session.get("profile", None)).first()
def get(self, request: Request):
return Response({
"user": serialize_user(request, request.user),
})
if not profile:
profile = Profile()
profile.save()
request.session["profile"] = profile.id
def post(self, request: Request):
"""
Login if the 'code' parameter is provided. Log out otherwise.
"""
# sign in
if request.method == "POST":
if not settings.GITHUB_CLIENT_ID or not settings.GITHUB_CLIENT_SECRET:
return Response({
"error": "GitHub sign-in not configured"
}, status=status.HTTP_501_NOT_IMPLEMENTED)
required_params = ["code"]
for param in required_params:
if param not in request.data:
return Response({"error": f"Missing parameter: {param}"}, status=status.HTTP_400_BAD_REQUEST)
logging.debug("Attempting GitHub oauth login")
response = requests.post(
"https://github.com/login/oauth/access_token",
json={
"client_id": settings.GITHUB_CLIENT_ID,
"client_secret": settings.GITHUB_CLIENT_SECRET,
"code": request.data["code"],
},
headers={ 'Accept': 'application/json' },
).json()
error = response.get("error")
if error == None:
access_token = response["access_token"]
profile.github_access_token = access_token
try:
assert profile.load_fields_from_github(always=True)
logging.debug("Connected existing profile to new GitHub user")
except GitHubUserChangeException:
# The token was for a different user than the one associated with the current profile,
# so make a new profile for this one.
profile = Profile()
profile.github_access_token = access_token
assert profile.load_fields_from_github(always=True)
logging.debug("Connected new profile to new GitHub user")
except GitHubUserHasExistingProfileException as e:
profile = e.profile
# This isn't strictly necessary, but we might as well use the renewed access token.
profile.github_access_token = access_token
assert profile.load_fields_from_github(always=True)
logging.debug("Swapped to existing profile for existing GitHub user")
profile.save()
request.session["profile"] = profile.id
elif error == "bad_verification_code":
return Response({
"error": "Invalid or expired GitHub OAuth verification code",
}, status=status.HTTP_401_UNAUTHORIZED)
if "code" in request.data:
GitHubUser.login(request, request.data["code"])
else:
raise Exception(f"Unknown GitHub login error: {error} - {response['error_description']}")
logout(request)
else:
profile.load_fields_from_github()
profile.save()
profile = Profile()
profile.save()
request.user.profile = profile
return Response({
"user": ProfileSerializer(profile).data,
})
return self.get(request)
@api_view(["GET"])
def user(request, username):
@@ -302,5 +250,6 @@ def user(request, username):
Gets a user's basic data
"""
user = get_object_or_404(Profile, username=username)
return Response(ProfileSerializer(user).data)
return Response({
"user": serialize_user(request, get_object_or_404(User, username=username)),
})

View File

@@ -19,6 +19,8 @@ env = environ.Env(
STATIC_URL=(str, '/static/'),
STATIC_ROOT=(str, BASE_DIR / 'static'),
USE_SANDBOX_JAIL=(bool, True),
GITHUB_CLIENT_ID=(str, ""),
GITHUB_CLIENT_SECRET=(str, ""),
)
env_file = BASE_DIR / ".." / ".env"
@@ -49,8 +51,9 @@ MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
#'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'coreapp.middleware.set_user_profile',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

View File

@@ -1,19 +0,0 @@
import { h } from "preact"
import { MarkGithubIcon, ChevronRightIcon } from "@primer/octicons-react"
export default function Unimplemented({ issue }: { issue: string }) {
return <div style={{ color: "#ffffff88" }}>
<p style={{ paddingTop: "0.5em", paddingBottom: "0.25em" }}>
There's meant to be more here, but it's not implemented yet.
</p>
<span style={{ padding: "0.5em", paddingLeft: "0" }}>
Think you could help?
</span>
{issue ? <a class="button" href={`https://github.com/ethteck/decomp.me/issues/${issue}`}>
<MarkGithubIcon /> See the GitHub issue <ChevronRightIcon />
</a> : <a class="button" href="https://github.com/ethteck/decomp.me">
<MarkGithubIcon /> Contribute to decomp.me on GitHub
</a>}
</div>
}

View File

@@ -43,7 +43,11 @@ export class ResponseError extends Error {
}
export async function get(url: string, cache = false) {
const response = await fetch(API_BASE + url, {
if (url.startsWith("/")) {
url = API_BASE + url
}
const response = await fetch(url, {
...commonOpts,
cache: cache ? "default" : "reload",
})
@@ -56,11 +60,15 @@ export async function get(url: string, cache = false) {
}
export async function post(url: string, json: Json) {
if (url.startsWith("/")) {
url = API_BASE + url
}
const body: string = JSON.stringify(json)
console.info("POST", url, JSON.parse(body))
const response = await fetch(API_BASE + url, {
const response = await fetch(url, {
...commonOpts,
method: "POST",
body,
@@ -78,11 +86,15 @@ export async function post(url: string, json: Json) {
}
export async function patch(url: string, json: Json) {
if (url.startsWith("/")) {
url = API_BASE + url
}
const body = JSON.stringify(json)
console.info("PATCH", url, JSON.parse(body))
const response = await fetch(API_BASE + url, {
const response = await fetch(url, {
...commonOpts,
method: "PATCH",
body,
@@ -104,11 +116,22 @@ export async function patch(url: string, json: Json) {
}
export interface AnonymousUser {
id: number,
is_you: boolean,
is_anonymous: true,
}
export interface FullUser extends AnonymousUser {
export interface User {
is_you: boolean,
is_anonymous: false,
id: number,
username: string,
name: string,
avatar_url: string,
avatar_url: string | null,
github_api_url: string | null,
github_html_url: string | null,
}
export function isAnonUser(user: User | AnonymousUser): user is AnonymousUser {
return user.is_anonymous
}

View File

@@ -16,14 +16,15 @@ import { useLocalStorage, useSize } from "../hooks"
import UserLink from "../user/UserLink"
import styles from "./Scratch.module.css"
import useSWR from "swr"
function nameScratch({ owner }: { owner: api.FullUser }, isYours = false): string {
if (isYours) {
return "Your scratch"
function nameScratch({ owner }: { owner: api.User }): string {
if (owner?.is_you) {
return "your scratch"
} else if (owner?.name) {
return `${owner?.name}'s scratch`
} else {
return "Unknown scratch"
return "unknown scratch"
}
}
@@ -34,13 +35,12 @@ export default function Scratch() {
const [currentRequest, setCurrentRequest] = useState("loading")
const [showWarnings, setShowWarnings] = useLocalStorage("logShowWarnings", false) // TODO: pass as compile flag '-wall'?
const [compiler, setCompiler] = useState<CompilerOptsT>(null)
const isCompilerChosen = !(compiler && compiler.compiler === "")
const isCompilerChosen = compiler?.compiler !== ""
const [cCode, setCCode] = useState(null)
const [cContext, setCContext] = useState(null)
const [diff, setDiff] = useState(null)
const [log, setLog] = useState(null)
const [isYours, setIsYours] = useState(false)
const [owner, setOwner] = useState<api.FullUser>(undefined) // XXX: type should really be AnonymousUser
const [owner, setOwner] = useState<api.User>(undefined)
const [parentScratch, setParentScratch] = useState(null)
const [savedCompiler, setSavedCompiler] = useState(compiler)
const [savedCCode, setSavedCCode] = useState(cCode)
@@ -52,12 +52,12 @@ export default function Scratch() {
const hasUnsavedChanges = savedCCode !== cCode || savedCContext !== cContext || JSON.stringify(savedCompiler) !== JSON.stringify(compiler)
useEffect(() => {
document.title = nameScratch({ owner }, isYours)
document.title = nameScratch({ owner })
if (hasUnsavedChanges) {
document.title += " (unsaved changes)"
}
}, [isYours, owner, hasUnsavedChanges])
}, [owner, hasUnsavedChanges])
const compile = async () => {
if (compiler === null || cCode === null || cContext === null) {
@@ -90,7 +90,7 @@ export default function Scratch() {
}
const save = async () => {
if (!isYours) {
if (!owner?.is_you) {
// Implicitly fork
return fork()
}
@@ -127,9 +127,8 @@ export default function Scratch() {
useEffect(() => {
(async () => {
const { scratch, is_yours } = await api.get(`/scratch/${slug}`)
const { scratch } = await api.get(`/scratch/${slug}`)
setIsYours(is_yours)
setOwner(scratch.owner)
setParentScratch(scratch.parent)
setCompiler({
@@ -200,18 +199,21 @@ export default function Scratch() {
<div class={styles.sectionHeader}>
Source
<span class={styles.grow} />
<button class={isCompiling ? styles.compiling : ""} onClick={compile} disabled={!isCompilerChosen}>
<SyncIcon size={16} /> Compile
</button>
{isYours && <button onClick={save}>
<UploadIcon size={16} /> Save
{hasUnsavedChanges && "*"}
</button>}
<button onClick={fork}>
<RepoForkedIcon size={16} /> Fork
</button>
<CompilerButton disabled={!isCompilerChosen} value={compiler} onChange={setCompiler} />
{isCompilerChosen && <>
<button class={isCompiling ? styles.compiling : ""} onClick={compile} disabled={!isCompilerChosen}>
<SyncIcon size={16} /> Compile
</button>
{owner?.is_you && <button onClick={save}>
<UploadIcon size={16} /> Save
{hasUnsavedChanges && "*"}
</button>}
<button onClick={fork}>
<RepoForkedIcon size={16} /> Fork
</button>
<CompilerButton disabled={!isCompilerChosen} value={compiler} onChange={setCompiler} />
</>}
</div>
<div class={styles.metadata}>
@@ -222,9 +224,7 @@ export default function Scratch() {
{parentScratch && <div>
Fork of
<Link to={`/scratch/${parentScratch.slug}`}>
{nameScratch(parentScratch)}
</Link>
<ScratchLink apiUrl={parentScratch} />
</div>}
</div>
@@ -326,3 +326,16 @@ function ChooseACompiler({ onCommit }) {
</div>
</div>
}
function ScratchLink({ apiUrl }: { apiUrl: string }) {
const { data } = useSWR(apiUrl, api.get)
const scratch = data?.scratch
if (!scratch) {
return <span />
}
return <Link to={`/scratch/${scratch.slug}`}>
{nameScratch(scratch)}
</Link>
}

View File

@@ -1,5 +1,6 @@
import { h } from "preact"
import { MarkGithubIcon } from "@primer/octicons-react"
import { useSWRConfig } from "swr"
const { GITHUB_CLIENT_ID } = import.meta.env
// https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps
@@ -8,8 +9,25 @@ const SCOPES = ["public_repo"]
const LOGIN_URL = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&scope=${SCOPES.join("%20")}`
export default function GitHubLoginButton() {
const { mutate } = useSWRConfig()
const showLoginWindow = (evt: MouseEvent) => {
const win = window.open(LOGIN_URL, "Sign in with GitHub", "resizable,scrollbars,status")
evt.preventDefault()
win.addEventListener("close", () => {
mutate("/user")
})
}
if (GITHUB_CLIENT_ID) {
return <a class="button" href={LOGIN_URL}>
return <a
class="button"
href={LOGIN_URL}
target="_blank"
rel="noreferrer"
onClick={showLoginWindow}
>
<MarkGithubIcon size={16} /> Sign in with GitHub
</a>
} else {

View File

@@ -12,7 +12,7 @@ import styles from "./LoginPage.module.css"
export default function LoginPage() {
const { searchParams } = new URL(document.location.href)
const code = searchParams.get("code")
const next = searchParams.get("next") || "/"
const next = searchParams.get("next")
const history = useHistory()
const [error, setError] = useState(null)
@@ -21,9 +21,13 @@ export default function LoginPage() {
useEffect(() => {
if (code) {
setError(null)
api.post("/user", { code }).then(({ user }: { user: api.FullUser }) => {
mutate("/user", { user })
history.replace(next)
api.post("/user", { code }).then(({ user }: { user: api.User }) => {
if (next) {
mutate("/user", { user })
history.replace(next)
} else {
window.close()
}
}).catch(error => {
console.error(error)
setError(error)

View File

@@ -7,11 +7,11 @@ import GitHubLoginButton from "./GitHubLoginButton"
import UserLink from "./UserLink"
export type Props = {
onChange?: (user: api.AnonymousUser) => void,
onChange?: (user: api.AnonymousUser | api.User) => void,
}
export default function LoginState({ onChange }: Props) {
const { data, error } = useSWR("/user", api.get)
const { data, error } = useSWR<{ user: api.AnonymousUser | api.User }>("/user", api.get)
useEffect(() => {
if (onChange && data?.user) {
@@ -24,8 +24,8 @@ export default function LoginState({ onChange }: Props) {
} else if (!data?.user) {
// Loading...
return <div />
} else if (data?.user?.username) {
return <UserLink username={data.user.username} />
} else if (data?.user && !api.isAnonUser(data.user) && data.user.username) {
return <UserLink username={data.user.username} hideYou={true} />
} else {
return <GitHubLoginButton />
}

View File

@@ -8,10 +8,12 @@ import styles from "./UserLink.module.css"
export type Props = {
username: string,
hideYou?: boolean,
}
export default function UserCard({ username }: Props) {
const { data: user, error } = useSWR<api.FullUser>(`/user/${username}`, api.get)
export default function UserCard({ username, hideYou }: Props) {
const { data, error } = useSWR<{ user: api.User }>(`/users/${username}`, api.get)
const user = data?.user
if (user) {
return <Link
@@ -19,8 +21,8 @@ export default function UserCard({ username }: Props) {
title={`@${user.username}`}
className={styles.user}
>
<img class={styles.avatar} src={user.avatar_url} alt="User avatar" />
<span>{user.name}</span>
{user.avatar_url && <img class={styles.avatar} src={user.avatar_url} alt="User avatar" />}
<span>{user.name} {!hideYou && user.is_you && <i>(you)</i>}</span>
</Link>
} else if (error) {
// TODO: handle error
@@ -28,7 +30,7 @@ export default function UserCard({ username }: Props) {
} else {
// TODO: loading state
return <div className={styles.user}>
<span>@{username}</span>
<span>{username}</span>
</div>
}
}

View File

@@ -35,10 +35,15 @@
}
.username {
opacity: 0.5;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5em;
color: #888;
font-weight: 400;
}
.username:hover {
border-bottom: 1px dotted;
.username a:hover {
color: white;
}

View File

@@ -1,47 +1,65 @@
import { h, Fragment } from "preact"
import { useState, useEffect } from "preact/hooks"
import { useEffect } from "preact/hooks"
import { useParams } from "react-router-dom"
import useSWR from "swr"
import useSWR, { useSWRConfig } from "swr"
import { MarkGithubIcon } from "@primer/octicons-react"
import * as api from "../api"
import Nav from "../Nav"
import Unimplemented from "../Unimplemented"
import styles from "./UserPage.module.css"
export default function UserPage() {
const { mutate } = useSWRConfig()
const { username } = useParams<{ username: string }>()
const { data: user, error } = useSWR<api.FullUser>(`/user/${username}`, api.get)
const [currentUser, setCurrentUser] = useState<api.AnonymousUser>(null)
const isCurrentUser = currentUser?.id === user?.id
const { data, error } = useSWR<{ user: api.User }>(`/users/${username}`, api.get)
const user = data?.user
useEffect(() => {
document.title = user?.name ? `${user.name} on decomp.me` : `${username} on decomp.me`
}, [username, user?.name])
const signOut = () => {
api.post("/user", {})
.then(({ user }: { user: api.AnonymousUser }) => {
mutate("/user", { user })
mutate(`/users/${username}`)
})
.catch(console.error)
}
if (user) {
return <>
<Nav onUserChange={setCurrentUser} />
<Nav />
<main class={styles.pageContainer}>
<section class={styles.userRow}>
<img
{user.avatar_url && <img
class={styles.avatar}
src={user.avatar_url}
alt="User avatar"
/>
/>}
<h1 class={styles.name}>
<div>{user.name} </div>
<a href={`https://github.com/${user.username}`} class={styles.username}>
{user.username} {isCurrentUser && "(you)"}
</a>
<div>{user.name} {user.is_you && <i>(you)</i>}</div>
<div class={styles.username}>
@{user.username}
{user.github_html_url && <a href={user.github_html_url}>
<MarkGithubIcon size={24} />
</a>}
</div>
</h1>
</section>
<section>
{/*<section>
<h2>Scratches</h2>
<ScratchList user={user} />
</section>
</section>*/}
{user.is_you && <section>
<button class="red" onClick={signOut}>
Sign out
</button>
</section>}
</main>
</>
} else if (error) {
@@ -63,11 +81,9 @@ export default function UserPage() {
}
}
export function ScratchList({ user }: { user: api.FullUser }) {
// TODO: needs backend
void user
/*
// TODO: needs backend
/*
export function ScratchList({ user }: { user: api.User }) {
const { data: scratches, error } = useSWR<api.Scratch[]>(`/user/${user.username}/scratches`, api.get)
if (scratches) {
@@ -77,7 +93,5 @@ export function ScratchList({ user }: { user: api.FullUser }) {
</li>)}
</ul>
}
*/
return <Unimplemented issue="105" />
}
*/