#12 Implement user invites

Merged
getty merged 9 commits from getty/gr/invites into getty/master 4 years ago
10 changed files with 285 additions and 9 deletions
  1. 40 2
      lc/app.py
  2. 33 2
      lc/error.py
  3. 56 1
      lc/model.py
  4. 19 0
      lc/request.py
  5. 16 0
      lc/static/main.css
  6. 6 3
      stubs/peewee.py
  7. 1 1
      tasks.py
  8. 25 0
      templates/add_user.mustache
  9. 46 0
      templates/config.mustache
  10. 43 0
      tests/model.py

+ 40 - 2
lc/app.py

@@ -61,9 +61,27 @@ class Logout(Endpoint):
 
 @endpoint("/u")
 class CreateUser(Endpoint):
+    def html(self):
+        if self.user:
+            raise e.LCRedirect(f"/u/{self.user.name}")
+
+        token = flask.request.args.get("token")
+        if not token:
+            raise e.LCRedirect("/")
+
+        return render(
+            "main",
+            title="add user",
+            user=self.user,
+            content=render("add_user", token=token),
+        )
+
     def api_post(self):
-        u = m.User.from_request(self.request_data(r.User))
-        return flask.redirect(u.base_url())
+        token = flask.request.args["token"]
+        req = self.request_data(r.NewUser).to_user_request()
+        u = m.User.from_invite(req, token)
+        flask.session["auth"] = req.to_token()
+        raise e.LCRedirect(u.base_url())
 
 
 @endpoint("/u/<string:slug>")
@@ -83,6 +101,26 @@ class GetUser(Endpoint):
         return m.User.by_slug(slug).to_dict()
 
 
+@endpoint("/u/<string:user>/config")
+class UserConfig(Endpoint):
+    def html(self, user: str):
+        u = self.require_authentication(user)
+        return render(
+            "main",
+            title="configuration",
+            content=render("config", **u.get_config()),
+            user=self.user,
+        )
+
+
+@endpoint("/u/<string:user>/invite")
+class CreateInvite(Endpoint):
+    def api_post(self, user: str):
+        u = self.require_authentication(user)
+        m.UserInvite.manufacture(u)
+        raise e.LCRedirect(f"/u/{user}/config")
+
+
 @endpoint("/u/<string:user>/l")
 class CreateLink(Endpoint):
     def html(self, user: str):

+ 33 - 2
lc/error.py

@@ -62,7 +62,7 @@ class BadPermissions(LCException):
         return f"Insufficient permissions."
 
     def http_code(self) -> int:
-        return 400
+        return 403
 
 
 @dataclass
@@ -73,4 +73,35 @@ class BadContentType(LCException):
         return f"Bad content type for request: {self.content_type}"
 
     def http_code(self) -> int:
-        return 500
+        return 403
+
+
+@dataclass
+class NoSuchInvite(LCException):
+    invite: str
+
+    def __str__(self):
+        return f"No such invite code: {self.invite}."
+
+    def http_code(self) -> int:
+        return 404
+
+
+@dataclass
+class AlreadyUsedInvite(LCException):
+    invite: str
+
+    def __str__(self):
+        return f"Invite code {self.invite} already taken."
+
+    def http_code(self) -> int:
+        return 403
+
+
+@dataclass
+class MismatchedPassword(LCException):
+    def __str__(self):
+        return f"Provided passwords do not match. Please check your passwords."
+
+    def http_code(self) -> int:
+        return 400

+ 56 - 1
lc/model.py

@@ -56,6 +56,17 @@ class User(Model):
         except peewee.IntegrityError:
             raise e.UserExists(name=user.name)
 
+    @staticmethod
+    def from_invite(user: r.User, token: str) -> "User":
+        invite = UserInvite.by_code(token)
+        if invite.claimed_by is not None or invite.claimed_at is not None:
+            raise e.AlreadyUsedInvite(invite=token)
+        u = User.from_request(user)
+        invite.claimed_at = datetime.datetime.now()
+        invite.claimed_by = u
+        invite.save()
+        return u
+
     def authenticate(self, password: str) -> bool:
         return pwd.verify(password, self.passhash)
 
@@ -99,6 +110,23 @@ class User(Model):
     def to_dict(self) -> dict:
         return {"id": self.id, "name": self.name}
 
+    def get_config(self) -> dict:
+        admin_pane = None
+        if self.is_admin:
+            user_invites = [
+                {
+                    "claimed": ui.claimed_by is not None,
+                    "claimant": ui.claimed_by and ui.claimed_by.name,
+                    "token": ui.token,
+                }
+                for ui in UserInvite.select().where(UserInvite.created_by == self)
+            ]
+            admin_pane = {"invites": user_invites}
+        return {
+            "username": self.name,
+            "admin_pane": admin_pane,
+        }
+
 
 class Link(Model):
     """
@@ -185,7 +213,33 @@ class HasTag(Model):
 
 
 class UserInvite(Model):
-    token: str
+    token = peewee.TextField(unique=True)
+
+    created_by = peewee.ForeignKeyField(User, backref="invites")
+    created_at = peewee.DateTimeField()
+
+    claimed_by = peewee.ForeignKeyField(User, null=True)
+    claimed_at = peewee.DateTimeField(null=True)
+
+    @staticmethod
+    def by_code(token: str) -> "UserInvite":
+        if (u := UserInvite.get_or_none(token=token)) :
+            return u
+        raise e.NoSuchInvite(invite=token)
+
+    @staticmethod
+    def manufacture(creator: User) -> "UserInvite":
+        now = datetime.datetime.now()
+        token = c.serializer.dumps(
+            {"created_at": now.timestamp(), "created_by": creator.name,}
+        )
+        return UserInvite.create(
+            token=token,
+            created_by=creator,
+            created_at=now,
+            claimed_by=None,
+            claimed_at=None,
+        )
 
 
 MODELS = [
@@ -193,6 +247,7 @@ MODELS = [
     Link,
     Tag,
     HasTag,
+    UserInvite,
 ]
 
 

+ 19 - 0
lc/request.py

@@ -5,6 +5,7 @@ from datetime import datetime
 from typing import List, Mapping, Optional, TypeVar, Type
 
 import lc.config as c
+import lc.error as e
 
 T = TypeVar("T")
 
@@ -37,6 +38,24 @@ class User(Request):
         return c.serializer.dumps({"name": self.name, "password": self.password,})
 
 
+@dataclass_json
+@dataclass
+class NewUser(Request):
+    name: str
+    n1: str
+    n2: str
+
+    @classmethod
+    def from_form(cls, form: Mapping[str, str]):
+        return cls(name=form["username"], n1=form["n1"], n2=form["n2"],)
+
+    def to_user_request(self) -> User:
+        if self.n1 != self.n2:
+            raise e.MismatchedPassword()
+
+        return User(name=self.name, password=self.n1)
+
+
 @dataclass_json
 @dataclass
 class Link(Request):

+ 16 - 0
lc/static/main.css

@@ -107,6 +107,22 @@ a {
     justify-content: center;
 }
 
+.config-pane {
+    display: flex;
+    justify-content: center;
+}
+
+.config {
+    margin: 2em;
+    padding: 2em;
+    border: solid black 1px;
+}
+
+.invite {
+    font-size: small;
+    color: #444;
+}
+
 label {
     display: inline-block;
     width: 6em;

+ 6 - 3
stubs/peewee.py

@@ -27,18 +27,21 @@ class Model:
     def select(self, expr: Optional[Expression] = None):
         pass
 
+    def save(self):
+        pass
+
 
 # These all do things that MyPy chokes on, so we're going to treat
 # them like methods instead of naming classes
-def TextField(default: str = "", unique: bool = False) -> Any:
+def TextField(default: str = "", unique: bool = False, null: bool = None) -> Any:
     pass
 
 
-def DateTimeField(unique: bool = False) -> Any:
+def DateTimeField(unique: bool = False, null: bool = None) -> Any:
     pass
 
 
-def BooleanField(default: bool = False, unique: bool = False) -> Any:
+def BooleanField(default: bool = False, unique: bool = False, null: bool = None) -> Any:
     pass
 
 

+ 1 - 1
tasks.py

@@ -5,7 +5,7 @@ from invoke import task
 @task
 def test(c):
     """Run all the provided tests"""
-    c.run("poetry run python -m pytest tests/*.py")
+    c.run("poetry run python -m pytest tests/*.py -W ignore::DeprecationWarning")
 
 
 @task

+ 25 - 0
templates/add_user.mustache

@@ -0,0 +1,25 @@
+<div class="config-pane">
+  <div class="config">
+    <div>
+      Sign up for Lament Configuration.<br/>
+      <span class="invite">(invite token: {{token}}).</token>
+      </span>
+  <form name="new-user" method="POST">
+    <div>
+      <label for="username">Username</label>
+      <input name="username" type="text" />
+    </div>
+    <div>
+      <label for="n1">Password</label>
+      <input name="n1" type="password" />
+    </div>
+    <div>
+      <label for="n2">Retype password</label>
+      <input name="n2" type="password" />
+    </div>
+    <div>
+      <input type="submit" value="Sign up" />
+    </div>
+  </form>
+  </div>
+</div>

+ 46 - 0
templates/config.mustache

@@ -0,0 +1,46 @@
+<div class="config-pane">
+  <div class="config">
+    Change password:
+    <form name="password-change" action="/u/{{username}}/password" method="POST">
+      <div>
+        <label for="old">Old:</label>
+        <input name="old" type="password" />
+      </div>
+      <div>
+        <label for="n1">New:</label>
+        <input name="n1" type="password" />
+      </div>
+      <div>
+        <label for="n2">New:</label>
+        <input name="n2" type="password" />
+      </div>
+      <div>
+        <input type="submit" value="Change Password" />
+      </div>
+    </form>
+  </div>
+</div>
+{{#admin_pane}}
+  <div class="config-pane">
+    <div class="config">
+      User Invites:
+      <ul>
+        {{#invites}}
+          {{#claimed}}
+            <li><span class="invite">{{token}}</span> claimed: <span class="user"><a href="/u/{{claimant}}">@{{claimant}}</a></span></li>
+          {{/claimed}}
+          {{^claimed}}
+            <li>Token <span class="invite"><a href="/u?token={{token}}">{{token}}</a></invite></li>
+          {{/claimed}}
+        {{/invites}}
+      </ul>
+      <div class="add-new">
+        <form name="new-invite" action="/u/{{username}}/invite" method="POST">
+          <div>
+            <input type="submit" value="Create invite link" />
+          </div>
+        </form>
+      </div>
+    </div>
+  </div>
+{{/admin_pane}}

+ 43 - 0
tests/model.py

@@ -90,3 +90,46 @@ class Testdb:
         # one already entered
         assert t.id == m.Tag.get(name="food/bread/rye").id
         assert t2.id == m.Tag.get(name="food/bread/baguette").id
+
+    def test_create_invite(self):
+        u = self.mk_user()
+        invite = m.UserInvite.manufacture(u)
+
+        # the invite should reference the user and be unclaimed
+        assert invite.created_by.id == u.id
+        assert invite.created_at is not None
+        assert invite.claimed_by is None
+        assert invite.claimed_at is None
+
+        # deserializing the unique token should reveal the encrypted data
+        raw_data = c.serializer.loads(invite.token)
+        assert raw_data["created_by"] == u.name
+
+    def test_use_invite(self):
+        u = self.mk_user()
+        initial_invite = m.UserInvite.manufacture(u)
+
+        assert initial_invite.claimed_by is None
+        assert initial_invite.claimed_at is None
+
+        u2 = m.User.from_invite(r.User(name="u2", password="u2"), initial_invite.token)
+
+        invite = m.UserInvite.by_code(initial_invite.token)
+        assert invite.token == initial_invite.token
+        assert invite.created_by.id == u.id
+        assert invite.claimed_by.id == u2.id
+        assert invite.created_at is not None
+        assert invite.claimed_at is not None
+
+    def bad_use_invite(self):
+        initial_invite = m.UserInvite.manufacture(self.mk_user())
+
+        # creating this user claims the invite
+        m.User.from_invite(r.User(name="u2", password="u2"), initial_invite.token)
+
+        # using the invite again raise an error
+        with pytest.raises(e.AlreadyUsedInvite):
+            m.User.from_invite(r.User(name="u3", password="u3"), initial_invite.token)
+
+        with pytest.raises(e.NoSuchInvite):
+            m.User.from_invite(r.User(name="u4", password="u4"), "a-non-existent-token")