feat: share repos with people, and invite people who have no account yet
Closes the last piece of the product picture: give one person access to some
of your repos and not others.
- POST /v1/grants add|remove|list -- runs on the CALLER'S OWN token. Verified
against the live forge that a repo owner's scoped token adds and removes
collaborators (204), so sharing needs no elevated rights anywhere.
- POST /v1/invite -- records a promise against a VERIFIED email and creates
nothing until it is redeemed. Applied on first link.
- client: share / shared / invite.
Three properties the tests pin:
* the FORGE decides who may share (listing collaborators requires repo
admin, so its 200 is the authorisation answer, not ours);
* an unverified email collects nothing, and its invite stays pending rather
than being consumed;
* a failed grant never blocks a sign-in -- nobody is locked out of their own
account because a repo they were promised has since been deleted.
Applying an invite uses the admin credential deliberately: the inviter
authorised it at invite time and their session is long gone by redemption.
198 tests (was 184).
This commit is contained in:
@@ -1339,6 +1339,76 @@ def cmd_bootstrap(args):
|
||||
return 0
|
||||
|
||||
|
||||
def _post(cfg, path, body):
|
||||
status, resp = http_json("POST", f"{cfg['server']}{path}", body=body)
|
||||
if status == 401:
|
||||
raise SystemExit("this device's access has been revoked -- "
|
||||
"run: granthi-sync link")
|
||||
return status, resp
|
||||
|
||||
|
||||
def cmd_share(args):
|
||||
"""Give someone who already has an account access to one of your repos."""
|
||||
cfg = require_linked(load_config())
|
||||
status, resp = _post(cfg, "/v1/grants",
|
||||
{"token": cfg["token"],
|
||||
"action": "remove" if args.revoke else "add",
|
||||
"repo": args.repo, "login": args.who,
|
||||
"permission": args.permission})
|
||||
if status == 403:
|
||||
raise SystemExit(f"you cannot administer {args.repo} -- only the "
|
||||
f"owner can share it")
|
||||
if status != 200:
|
||||
raise SystemExit(f"share failed (HTTP {status}): {resp}")
|
||||
verb = "no longer shared with" if args.revoke else \
|
||||
f"shared with ({resp.get('permission')})"
|
||||
log(f"{resp.get('repo')} {verb} {resp.get('login')}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_shared(args):
|
||||
"""Who can see one of your repos."""
|
||||
cfg = require_linked(load_config())
|
||||
status, resp = _post(cfg, "/v1/grants",
|
||||
{"token": cfg["token"], "action": "list",
|
||||
"repo": args.repo})
|
||||
if status == 403:
|
||||
raise SystemExit(f"you cannot administer {args.repo}")
|
||||
if status != 200:
|
||||
raise SystemExit(f"could not list access (HTTP {status}): {resp}")
|
||||
people = resp.get("collaborators") or []
|
||||
if not people:
|
||||
print(f"{resp.get('repo')}: nobody else has access")
|
||||
return 0
|
||||
print(f"{resp.get('repo')} is shared with:")
|
||||
for p in people:
|
||||
print(f" {p}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_invite(args):
|
||||
"""Promise access to someone who has no account yet.
|
||||
|
||||
Nothing is created for them now. The grant is held against their email and
|
||||
applied the first time they sign in -- so an invite that is never accepted
|
||||
leaves nothing behind.
|
||||
"""
|
||||
cfg = require_linked(load_config())
|
||||
repos = [{"name": r, "permission": args.permission} for r in args.repo]
|
||||
status, resp = _post(cfg, "/v1/invite",
|
||||
{"token": cfg["token"], "email": args.email,
|
||||
"repos": repos})
|
||||
if status == 403:
|
||||
raise SystemExit(str(resp.get("error") or "you cannot share that repo"))
|
||||
if status != 200:
|
||||
raise SystemExit(f"invite failed (HTTP {status}): {resp}")
|
||||
log(f"invited {resp['invited']} to "
|
||||
f"{', '.join(g['repo'] for g in resp['repos'])}")
|
||||
log("they get access the first time they run `granthi-sync link` with "
|
||||
"that email -- until then nothing exists for them")
|
||||
return 0
|
||||
|
||||
|
||||
def _folder_meta(cfg, folder):
|
||||
path = os.path.abspath(folder)
|
||||
meta = cfg.get("folders", {}).get(path)
|
||||
@@ -1552,6 +1622,28 @@ def main(argv=None):
|
||||
sp.add_argument("--dry-run", action="store_true")
|
||||
sp.set_defaults(fn=cmd_bootstrap)
|
||||
|
||||
sp = sub.add_parser("share", help="share one of your repos with someone")
|
||||
sp.add_argument("who", help="their forge login")
|
||||
sp.add_argument("--repo", required=True)
|
||||
sp.add_argument("--permission", default="write",
|
||||
choices=("read", "write", "admin"))
|
||||
sp.add_argument("--revoke", action="store_true",
|
||||
help="take the access away again")
|
||||
sp.set_defaults(fn=cmd_share)
|
||||
|
||||
sp = sub.add_parser("shared", help="who can see one of your repos")
|
||||
sp.add_argument("--repo", required=True)
|
||||
sp.set_defaults(fn=cmd_shared)
|
||||
|
||||
sp = sub.add_parser("invite",
|
||||
help="invite someone who has no account yet")
|
||||
sp.add_argument("email")
|
||||
sp.add_argument("--repo", action="append", required=True,
|
||||
help="repeatable")
|
||||
sp.add_argument("--permission", default="write",
|
||||
choices=("read", "write", "admin"))
|
||||
sp.set_defaults(fn=cmd_invite)
|
||||
|
||||
sp = sub.add_parser("status", help="show linked folders")
|
||||
sp.set_defaults(fn=cmd_status)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user