← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ilasc/launchpad:person-oci-registry-credentials-edit into launchpad:master

 

Ioana Lasc has proposed merging ~ilasc/launchpad:person-oci-registry-credentials-edit into launchpad:master.

Commit message:
Add edit screen for OCIRegistryCredentials on person page

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ilasc/launchpad/+git/launchpad/+merge/385825
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ilasc/launchpad:person-oci-registry-credentials-edit into launchpad:master.
diff --git a/lib/lp/oci/interfaces/ocipushrule.py b/lib/lp/oci/interfaces/ocipushrule.py
index b889cdd..67db23e 100644
--- a/lib/lp/oci/interfaces/ocipushrule.py
+++ b/lib/lp/oci/interfaces/ocipushrule.py
@@ -127,5 +127,11 @@ class IOCIPushRuleSet(Interface):
     def new(recipe, registry_credentials, image_name):
         """Create an `IOCIPushRule`."""
 
+    def findByRecipe(self, recipe):
+        """Find matching `IOCIPushRule`s by recipe."""
+
+    def findByRegistryCredentials(self, credentials):
+        """Find matching `IOCIPushRule` by credentials."""
+
     def getByID(id):
         """Get a single `IOCIPushRule` by its ID."""
diff --git a/lib/lp/oci/model/ocipushrule.py b/lib/lp/oci/model/ocipushrule.py
index dbdb22e..24d5e27 100644
--- a/lib/lp/oci/model/ocipushrule.py
+++ b/lib/lp/oci/model/ocipushrule.py
@@ -69,7 +69,7 @@ class OCIPushRule(Storm):
 
     def destroySelf(self):
         """See `IOCIPushRule`."""
-        IStore(OCIPushRule).get(self.id).remove()
+        IStore(OCIPushRule).remove(self)
 
 
 @implementer(IOCIPushRuleSet)
@@ -90,3 +90,15 @@ class OCIPushRuleSet:
     def getByID(self, id):
         """See `IOCIPushRuleSet`."""
         return IStore(OCIPushRule).get(OCIPushRule, id)
+
+    def findByRecipe(self, recipe):
+        store = IStore(OCIPushRule)
+        return store.find(
+            OCIPushRule,
+            OCIPushRule.recipe == recipe)
+
+    def findByRegistryCredentials(self, credentials):
+        store = IStore(OCIPushRule)
+        return store.find(
+            OCIPushRule,
+            OCIPushRule.registry_credentials == credentials)
diff --git a/lib/lp/oci/model/ociregistrycredentials.py b/lib/lp/oci/model/ociregistrycredentials.py
index 49c740f..d86b140 100644
--- a/lib/lp/oci/model/ociregistrycredentials.py
+++ b/lib/lp/oci/model/ociregistrycredentials.py
@@ -122,11 +122,13 @@ class OCIRegistryCredentials(Storm):
     def username(self):
         return self._credentials.get('username')
 
+    @username.setter
+    def username(self, value):
+        self._credentials['username'] = value
+
     def destroySelf(self):
         """See `IOCIRegistryCredentials`."""
-        store = IStore(OCIRegistryCredentials)
-        store.find(
-            OCIRegistryCredentials, OCIRegistryCredentials.id == self).remove()
+        IStore(OCIRegistryCredentials).remove(self)
 
 
 @implementer(IOCIRegistryCredentialsSet)
diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
index 5e1489b..97a1a93 100644
--- a/lib/lp/registry/browser/configure.zcml
+++ b/lib/lp/registry/browser/configure.zcml
@@ -1238,6 +1238,12 @@
         template="../templates/person-ociregistrycredentials.pt"
         />
     <browser:page
+        for="lp.registry.interfaces.person.IPerson"
+        class="lp.registry.browser.person.PersonEditOCIRegistryCredentialsView"
+        permission="launchpad.Edit"
+        name="+edit-oci-registry-credentials"
+        template="../templates/person-edit-ociregistrycredentials.pt" />
+    <browser:page
         name="+livefs"
         for="lp.registry.interfaces.person.IPerson"
         class="lp.registry.browser.person.PersonLiveFSView"
diff --git a/lib/lp/registry/browser/person.py b/lib/lp/registry/browser/person.py
index 535cf4d..227d97b 100644
--- a/lib/lp/registry/browser/person.py
+++ b/lib/lp/registry/browser/person.py
@@ -7,6 +7,7 @@ __metaclass__ = type
 __all__ = [
     'BeginTeamClaimView',
     'CommonMenuLinks',
+    'PersonEditOCIRegistryCredentialsView',
     'EmailToPersonView',
     'PeopleSearchView',
     'PersonAccountAdministerView',
@@ -90,7 +91,9 @@ from zope.interface import (
 from zope.interface.exceptions import Invalid
 from zope.publisher.interfaces import NotFound
 from zope.schema import (
+    Bool,
     Choice,
+    Password,
     Text,
     TextLine,
     )
@@ -134,8 +137,10 @@ from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
 from lp.code.errors import InvalidNamespace
 from lp.code.interfaces.branchnamespace import IBranchNamespaceSet
 from lp.code.interfaces.gitlookup import IGitTraverser
+from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
 from lp.oci.interfaces.ociregistrycredentials import (
     IOCIRegistryCredentialsSet,
+    OCIRegistryCredentialsAlreadyExist,
     )
 from lp.registry.browser import BaseRdfView
 from lp.registry.browser.branding import BrandingChangeView
@@ -3659,6 +3664,291 @@ class PersonOCIRegistryCredentialsView(LaunchpadView):
         return len(self.oci_registry_credentials) > 0
 
 
+class PersonEditOCIRegistryCredentialsView(LaunchpadFormView):
+    """View for Person:+edit-oci-registry-credentials."""
+
+    @cachedproperty
+    def oci_registry_credentials(self):
+        return list(getUtility(
+            IOCIRegistryCredentialsSet).findByOwner(self.context))
+
+    class schema(Interface):
+        """Schema for editing registry credentials."""
+
+    def _getFieldName(self, name, credentials_id):
+        """Get the combined field name for an `OCIRegistryCredentials` ID.
+
+        In order to be able to render a table, we encode the credentials ID
+        in the form field name.
+        """
+        return "%s.%d" % (name, credentials_id)
+
+    def _parseFieldName(self, field_name):
+        """Parse a combined field name as described in `_getFieldName`.
+
+        :raises UnexpectedFormData: if the field name cannot be parsed or
+            the `OCIRegistryCredentials` cannot be found.
+        """
+        field_bits = field_name.split(".")
+        if len(field_bits) != 2:
+            raise UnexpectedFormData(
+                "Cannot parse field name: %s" % field_name)
+        field_type = field_bits[0]
+        try:
+            credentials_id = int(field_bits[1])
+        except ValueError:
+            raise UnexpectedFormData(
+                "Cannot parse field name: %s" % field_name)
+        return field_type, credentials_id
+
+    def setUpWidgets(self):
+        LaunchpadFormView.setUpWidgets(self)
+
+    def setUpFields(self):
+        """See `LaunchpadFormView`."""
+        LaunchpadFormView.setUpFields(self)
+
+        owner_fields = []
+        username_fields = []
+        password_fields = []
+        url_fields = []
+        delete_fields = []
+
+        for elem in self.oci_registry_credentials:
+            owner_fields.append(
+                Choice(
+                    title=u'Owner',
+                    vocabulary=(
+                        'AllUserTeamsParticipationPlusSelfSimpleDisplay'),
+                    default=elem.owner.name,
+                    __name__=self._getFieldName('owner', elem.id)))
+            username_fields.append(
+                TextLine(
+                    title=u'Username',
+                    __name__=self._getFieldName('username', elem.id),
+                    default=elem.username,
+                    required=False, readonly=False))
+            password_fields.append(
+                Password(
+                    title=u'Password',
+                    __name__=self._getFieldName('password', elem.id),
+                    default=None,
+                    required=False, readonly=False))
+            password_fields.append(
+                Password(
+                    title=u'Confirm password',
+                    __name__=self._getFieldName('confirm_password', elem.id),
+                    default=None,
+                    required=False, readonly=False))
+            url_fields.append(
+                TextLine(
+                    title=u'Registry URL',
+                    __name__=self._getFieldName('url', elem.id),
+                    default=elem.url,
+                    required=True, readonly=False))
+            delete_fields.append(
+                Bool(
+                    title=u'Delete',
+                    __name__=self._getFieldName('delete', elem.id),
+                    default=False,
+                    required=True, readonly=False))
+        # The fields from the Add New Credentials
+        url_fields.append(
+            TextLine(
+                title=u'Registry URL',
+                __name__=u'add_url',
+                required=False, readonly=False))
+        username_fields.append(
+            TextLine(
+                title=u'Username',
+                __name__=u'add_username',
+                required=False, readonly=False))
+        password_fields.append(
+            Password(
+                title=u'Password',
+                __name__=u'add_password',
+                required=False, readonly=False))
+        password_fields.append(
+            Password(
+                title=u'Confirm password',
+                __name__=u'add_confirm_password',
+                required=False, readonly=False))
+        self.form_fields += FormFields(*owner_fields)
+        self.form_fields += FormFields(*username_fields)
+        self.form_fields += FormFields(*password_fields)
+        self.form_fields += FormFields(*url_fields)
+        self.form_fields += FormFields(*delete_fields)
+
+    @property
+    def label(self):
+        return 'Edit OCI registry credentials'
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    def getCredentialsWidgets(self, credentials):
+        widgets_by_name = {widget.name: widget for widget in self.widgets}
+        owner_field_name = (
+                "field." + self._getFieldName("owner", credentials.id))
+        username_field_name = (
+                "field." + self._getFieldName("username", credentials.id))
+        password_field_name = (
+                "field." + self._getFieldName("password", credentials.id))
+        confirm_password_field_name = (
+                "field." + self._getFieldName("confirm_password",
+                                              credentials.id))
+        url_field_name = "field." + self._getFieldName("url", credentials.id)
+        delete_field_name = (
+                "field." + self._getFieldName("delete", credentials.id))
+        return {
+            "owner": widgets_by_name[owner_field_name],
+            "username": widgets_by_name[username_field_name],
+            "password": widgets_by_name[password_field_name],
+            "confirm_password": widgets_by_name[confirm_password_field_name],
+            "url": widgets_by_name[url_field_name],
+            "delete": widgets_by_name[delete_field_name]
+        }
+
+    def parseData(self, data):
+        """Rearrange form data to make it easier to process."""
+        parsed_data = {}
+        add_url = data["add_url"]
+        add_username = data["add_username"]
+        add_password = data["add_password"]
+        add_confirm_password = data["add_confirm_password"]
+        if add_url or add_username or add_password or add_confirm_password:
+            parsed_data.setdefault(None, {
+                "username": add_username,
+                "password": add_password,
+                "confirm_password": add_confirm_password,
+                "url": add_url,
+                "action": "add",
+            })
+        for field_name in sorted(
+                name for name in data if name.split(".")[0] == "owner"):
+            _, credentials_id = self._parseFieldName(field_name)
+            owner_field_name = self._getFieldName(
+                "owner", credentials_id)
+            username_field_name = self._getFieldName(
+                "username", credentials_id)
+            password_field_name = self._getFieldName(
+                "password", credentials_id)
+            confirm_password_field_name = self._getFieldName(
+                "confirm_password", credentials_id)
+            url_field_name = self._getFieldName("url", credentials_id)
+            delete_field_name = self._getFieldName("delete", credentials_id)
+            if data.get(delete_field_name):
+                action = "delete"
+            else:
+                action = "change"
+            parsed_data.setdefault(credentials_id, {
+                "username": data.get(username_field_name),
+                "password": data.get(password_field_name),
+                "confirm_password": data.get(confirm_password_field_name),
+                "url": data.get(url_field_name),
+                "owner": data.get(owner_field_name),
+                "action": action,
+            })
+
+        return parsed_data
+
+    def updateCredentialsFromData(self, parsed_data):
+        credentials_map = {
+            credentials.id: credentials
+            for credentials in self.oci_registry_credentials}
+
+        for credentials_id, parsed_credentials in parsed_data.items():
+            credentials = credentials_map.get(credentials_id)
+            action = parsed_credentials["action"]
+
+            if action == "change":
+                username = parsed_credentials["username"]
+                password = parsed_credentials["password"]
+                confirm_password = parsed_credentials["confirm_password"]
+                owner = parsed_credentials["owner"]
+                if password or confirm_password:
+                    if password != confirm_password:
+                        self.setFieldError(
+                            self._getFieldName(
+                                "confirm_password", credentials_id),
+                            "Passwords do not match.")
+                    else:
+                        credentials.setCredentials({
+                            "username": username,
+                            "password": password,
+                        })
+                elif username != credentials.username:
+                    removeSecurityProxy(credentials).username = username
+                elif parsed_credentials["url"] != credentials.url:
+                    credentials.url = parsed_credentials["url"]
+                if owner != credentials.owner:
+                    credentials.owner = owner
+            elif action == "delete":
+                push_rule_set = getUtility(IOCIPushRuleSet)
+                if not push_rule_set.findByRegistryCredentials(
+                        credentials).is_empty():
+                    self.setFieldError(
+                        self._getFieldName("delete", credentials_id),
+                        "These credentials cannot be deleted as there are "
+                        "push rules defined that still use them.")
+                else:
+                    credentials.destroySelf()
+            elif action == "add":
+                parsed_add_credentials = parsed_data[credentials]
+                url = parsed_add_credentials["url"]
+                password = parsed_add_credentials["password"]
+                confirm_password = parsed_add_credentials["confirm_password"]
+                username = parsed_add_credentials["username"]
+                if url:
+                    if password or confirm_password:
+                        if password == confirm_password:
+                            credentials = {
+                                'username': username,
+                                'password': password}
+                            try:
+                                getUtility(IOCIRegistryCredentialsSet).new(
+                                    owner=removeSecurityProxy(self.context),
+                                    url=url,
+                                    credentials=credentials)
+                            except OCIRegistryCredentialsAlreadyExist:
+                                self.setFieldError("add_url",
+                                                   "Credentials already exist "
+                                                   "with the same URL and "
+                                                   "username.")
+                        else:
+                            self.setFieldError("add_password",
+                                               "Please make sure the new "
+                                               "password matches the "
+                                               "confirm password field.")
+                    else:
+                        credentials = {'username': username}
+                        try:
+                            getUtility(IOCIRegistryCredentialsSet).new(
+                                owner=removeSecurityProxy(self.context),
+                                url=url,
+                                credentials=credentials)
+                        except OCIRegistryCredentialsAlreadyExist:
+                            self.setFieldError("add_url",
+                                               "Credentials already exist "
+                                               "with the same URL and "
+                                               "username.")
+                else:
+                    self.setFieldError("add_url",
+                                       "Registry URL cannot be empty.")
+            else:
+                raise AssertionError("unknown action: %s" % action)
+
+    @action("Save")
+    def save(self, action, data):
+        parsed_data = self.parseData(data)
+        self.updateCredentialsFromData(parsed_data)
+
+        if not self.errors:
+            self.request.response.addNotification("Saved credentials")
+            self.next_url = canonical_url(self.context)
+
+
 class PersonLiveFSView(LaunchpadView):
     """Default view for the list of live filesystems owned by a person."""
     page_title = 'LiveFS'
diff --git a/lib/lp/registry/browser/tests/test_person.py b/lib/lp/registry/browser/tests/test_person.py
index 0d5bce9..3e6ebea 100644
--- a/lib/lp/registry/browser/tests/test_person.py
+++ b/lib/lp/registry/browser/tests/test_person.py
@@ -19,6 +19,7 @@ from testtools.matchers import (
     DocTestMatches,
     Equals,
     LessThan,
+    MatchesDict,
     Not,
     )
 from testtools.testcase import ExpectedException
@@ -34,6 +35,7 @@ from lp.app.errors import NotFoundError
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.blueprints.enums import SpecificationImplementationStatus
 from lp.buildmaster.enums import BuildStatus
+from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
 from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
 from lp.oci.interfaces.ociregistrycredentials import (
     IOCIRegistryCredentialsSet,
@@ -53,6 +55,7 @@ from lp.registry.model.karma import KarmaCategory
 from lp.registry.model.milestone import milestone_sort_key
 from lp.scripts.garbo import PopulateLatestPersonSourcePackageReleaseCache
 from lp.services.config import config
+from lp.services.database.interfaces import IStore
 from lp.services.features.testing import FeatureFixture
 from lp.services.identity.interfaces.account import AccountStatus
 from lp.services.identity.interfaces.emailaddress import IEmailAddressSet
@@ -1350,6 +1353,167 @@ class TestPersonOCIRegistryCredentialsView(BrowserTestCase,
                 view.oci_registry_credentials[0].getCredentials()['username'])
             self.assertEqual(url, view.oci_registry_credentials[0].url)
 
+    def test_edit_oci_registry_creds_on_person_page(self):
+        url = unicode(self.factory.getUniqueURL())
+        newurl = unicode(self.factory.getUniqueURL())
+        third_url = unicode(self.factory.getUniqueURL())
+        credentials = {'username': 'foo', 'password': 'bar'}
+        credentials2 = {'username': 'foo2', 'password': 'bar'}
+        registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
+            owner=self.user,
+            url=url,
+            credentials=credentials)
+        IStore(registry_credentials).flush()
+        registry_credentials_id = removeSecurityProxy(registry_credentials).id
+        getUtility(IOCIRegistryCredentialsSet).new(
+            owner=self.user,
+            url=newurl,
+            credentials=credentials2)
+        browser = self.getViewBrowser(
+            self.user, view_name='+oci-registry-credentials', user=self.user)
+        browser.getLink("Edit OCI registry credentials").click()
+
+        # Change only the username
+        username_control = browser.getControl(
+            name="field.username.%d" % registry_credentials_id)
+        username_control.value = 'different_username'
+        browser.getControl("Save").click()
+        with person_logged_in(self.user):
+            self.assertThat(
+                registry_credentials.getCredentials(),
+                MatchesDict(
+                    {"username": Equals("different_username"),
+                     "password": Equals("bar")}))
+
+        # change only the registry url
+        browser = self.getViewBrowser(
+            self.user, view_name='+oci-registry-credentials', user=self.user)
+        browser.getLink("Edit OCI registry credentials").click()
+        url_control = browser.getControl(
+            name="field.url.%d" % registry_credentials_id)
+        url_control.value = newurl
+        browser.getControl("Save").click()
+        with person_logged_in(self.user):
+            self.assertEqual(newurl, registry_credentials.url)
+
+        # change only the password
+        browser = self.getViewBrowser(
+            self.user, view_name='+oci-registry-credentials', user=self.user)
+        browser.getLink("Edit OCI registry credentials").click()
+        password_control = browser.getControl(
+            name="field.password.%d" % registry_credentials_id)
+        password_control.value = 'newpassword'
+
+        browser.getControl("Save").click()
+        self.assertIn("Passwords do not match.", browser.contents)
+
+        # change all fields with one edit action
+        username_control = browser.getControl(
+            name="field.username.%d" % registry_credentials_id)
+        username_control.value = 'third_different_username'
+        url_control = browser.getControl(
+            name="field.url.%d" % registry_credentials_id)
+        url_control.value = third_url
+        password_control = browser.getControl(
+            name="field.password.%d" % registry_credentials_id)
+        password_control.value = 'third_newpassword'
+        confirm_password_control = browser.getControl(
+            name="field.confirm_password.%d" % registry_credentials_id)
+        confirm_password_control.value = 'third_newpassword'
+        browser.getControl("Save").click()
+        with person_logged_in(self.user):
+            self.assertThat(
+                registry_credentials.getCredentials(),
+                MatchesDict(
+                    {"username": Equals("third_different_username"),
+                     "password": Equals("third_newpassword")}))
+            self.assertEqual(third_url, registry_credentials.url)
+
+    def test_add_oci_registry_creds_on_person_page(self):
+        url = unicode(self.factory.getUniqueURL())
+        credentials = {'username': 'foo', 'password': 'bar'}
+        image_name = self.factory.getUniqueUnicode()
+        registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
+            owner=self.user,
+            url=url,
+            credentials=credentials)
+        getUtility(IOCIPushRuleSet).new(
+            recipe=self.recipe,
+            registry_credentials=registry_credentials,
+            image_name=image_name)
+
+        browser = self.getViewBrowser(
+            self.user, view_name='+oci-registry-credentials', user=self.user)
+        browser.getLink("Edit OCI registry credentials").click()
+
+        browser.getControl(name="field.add_url").value = url
+        browser.getControl(name="field.add_username").value = "new_username"
+        browser.getControl(name="field.add_password").value = "password"
+        browser.getControl(
+            name="field.add_confirm_password").value = "password"
+        browser.getControl("Save").click()
+
+        with person_logged_in(self.user):
+            creds = list(getUtility(
+                IOCIRegistryCredentialsSet).findByOwner(
+                self.user))
+
+        self.assertEqual(url, creds[1].url)
+        self.assertThat(
+        removeSecurityProxy(creds[1]).getCredentials(),
+            MatchesDict({"username": Equals("new_username"),
+            "password": Equals("password")}))
+
+    def test_delete_oci_registry_creds_on_person_page(self):
+        # Test that we do not delete creds when there are
+        # push rules defined to use them
+        url = unicode(self.factory.getUniqueURL())
+        credentials = {'username': 'foo', 'password': 'bar'}
+        registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
+            owner=self.person,
+            url=url,
+            credentials=credentials)
+        IStore(registry_credentials).flush()
+        registry_credentials_id = removeSecurityProxy(registry_credentials).id
+        image_name = self.factory.getUniqueUnicode()
+        push_rule = getUtility(IOCIPushRuleSet).new(
+            recipe=self.recipe,
+            registry_credentials=registry_credentials,
+            image_name=image_name)
+
+        browser = self.getViewBrowser(
+            self.person, view_name='+oci-registry-credentials',
+            user=self.person)
+        browser.getLink("Edit OCI registry credentials").click()
+        # assert full rule is displayed
+        self.assertEqual(url, browser.getControl(
+            name="field.url.%d" % registry_credentials_id).value)
+        self.assertEqual(credentials.get('username'), browser.getControl(
+            name="field.username.%d" % registry_credentials_id).value)
+
+        # mark one line of credentials for delete
+        delete_control = browser.getControl(
+            name="field.delete.%d" % registry_credentials_id)
+        delete_control.getControl('Delete').selected = True
+        browser.getControl("Save").click()
+        self.assertIn("These credentials cannot be deleted as there are "
+                      "push rules defined that still use them.",
+                      browser.contents)
+
+        # make sure we don't have any push rules defined to use
+        # the credentials we want to remove
+        with person_logged_in(self.person):
+            removeSecurityProxy(push_rule).destroySelf()
+
+        delete_control = browser.getControl(
+            name="field.delete.%d" % registry_credentials_id)
+        delete_control.getControl('Delete').selected = True
+        browser.getControl("Save").click()
+        credentials_set = getUtility(IOCIRegistryCredentialsSet)
+        with person_logged_in(self.person):
+            self.assertEqual(
+                0, credentials_set.findByOwner(self.person).count())
+
 
 class TestPersonLiveFSView(BrowserTestCase):
     layer = DatabaseFunctionalLayer
diff --git a/lib/lp/registry/templates/person-edit-ociregistrycredentials.pt b/lib/lp/registry/templates/person-edit-ociregistrycredentials.pt
new file mode 100644
index 0000000..89483da
--- /dev/null
+++ b/lib/lp/registry/templates/person-edit-ociregistrycredentials.pt
@@ -0,0 +1,59 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+    <div metal:use-macro="context/@@launchpad_form/form">
+    <metal:formbody fill-slot="widgets">
+
+    <table class="form">
+        <tr tal:repeat="credentials view/oci_registry_credentials">
+            <tal:credentials_widgets
+              define="credentials_widgets python:view.getCredentialsWidgets(credentials);
+                      parity python:'even' if repeat['credentials'].even() else 'odd'">
+                <td tal:define="widget nocall:credentials_widgets/url">
+                    <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+                </td>
+                <td tal:define="widget nocall:credentials_widgets/owner">
+                    <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+                </td>
+                <td tal:define="widget nocall:credentials_widgets/username">
+                    <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+                </td>
+                <td tal:define="widget nocall:credentials_widgets/password">
+                    <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+                </td>
+                <td tal:define="widget nocall:credentials_widgets/confirm_password">
+                    <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+                </td>
+                <td tal:define="widget nocall:credentials_widgets/delete">
+                    <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+                </td>
+            </tal:credentials_widgets>
+        </tr>
+        <tr>
+            <td tal:define="widget nocall:view/widgets/add_url">
+                <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+            </td>
+            <td tal:define="widget nocall:view/widgets/add_username">
+                <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+            </td>
+            <td tal:define="widget nocall:view/widgets/add_password">
+                <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+            </td>
+            <td tal:define="widget nocall:view/widgets/add_confirm_password">
+                <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+            </td>
+        </tr>
+    </table>
+
+    </metal:formbody>
+    </div>
+</div>
+</body>
+</html>
diff --git a/lib/lp/registry/templates/person-ociregistrycredentials.pt b/lib/lp/registry/templates/person-ociregistrycredentials.pt
index 612056e..bec71e1 100644
--- a/lib/lp/registry/templates/person-ociregistrycredentials.pt
+++ b/lib/lp/registry/templates/person-ociregistrycredentials.pt
@@ -12,6 +12,10 @@
         <span tal:replace="context/title"/>
         has not set any credentials yet.
     </p>
+    <p condition="context/required:launchpad.Edit">
+        <a class="sprite edit" tal:attributes="href context/fmt:url/+edit-oci-registry-credentials">Edit OCI registry credentials</a>
+    </p>
+
     <table id="oci-credentials" class="listing" tal:condition="view/has_credentials">
     <thead>
         <tr>

Follow ups